feat: add secure settings foundation

This commit is contained in:
姚章浩
2026-08-13 12:38:01 +08:00
parent 9363d10e44
commit 8d2d4b469b
18 changed files with 549 additions and 0 deletions
@@ -0,0 +1 @@
DROP TABLE IF EXISTS config_audit_events; DROP TABLE IF EXISTS system_resource_bindings; DROP TABLE IF EXISTS administrators; DROP TABLE IF EXISTS system_config_revisions;
@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS system_config_revisions (
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, kind TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0),
state TEXT NOT NULL CHECK(state IN ('draft','validated','active','retired','failed')), public_config JSONB NOT NULL DEFAULT '{}'::JSONB,
secret_ref TEXT NOT NULL DEFAULT '', validation_status TEXT NOT NULL DEFAULT 'pending', validation_code TEXT NOT NULL DEFAULT '',
validated_at TIMESTAMPTZ, activated_at TIMESTAMPTZ, created_by_staff_id TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_key,kind,revision));
CREATE UNIQUE INDEX IF NOT EXISTS uq_system_config_active ON system_config_revisions(tenant_key,kind) WHERE state='active';
CREATE TABLE IF NOT EXISTS administrators (
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, staff_id TEXT NOT NULL, union_id TEXT NOT NULL DEFAULT '', display_name TEXT NOT NULL DEFAULT '',
department TEXT NOT NULL DEFAULT '', role TEXT NOT NULL CHECK(role IN ('super_admin','admin')), status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','disabled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(tenant_key,staff_id));
CREATE TABLE IF NOT EXISTS system_resource_bindings (
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, kind TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0), state TEXT NOT NULL CHECK(state IN ('draft','validated','active','retired','failed')),
public_config JSONB NOT NULL DEFAULT '{}'::JSONB, validation_code TEXT NOT NULL DEFAULT '', validated_at TIMESTAMPTZ, activated_at TIMESTAMPTZ,
created_by_staff_id TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(tenant_key,kind,revision));
CREATE UNIQUE INDEX IF NOT EXISTS uq_resource_binding_active ON system_resource_bindings(tenant_key,kind) WHERE state='active';
CREATE TABLE IF NOT EXISTS config_audit_events (
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, actor_staff_id TEXT NOT NULL DEFAULT '', action TEXT NOT NULL, target_kind TEXT NOT NULL,
target_revision INTEGER NOT NULL DEFAULT 0, result_code TEXT NOT NULL, metadata JSONB NOT NULL DEFAULT '{}'::JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
@@ -0,0 +1,25 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp012CreatesSettingsWithoutSecrets(t *testing.T) {
b, e := os.ReadFile("000012_system_settings.up.sql")
if e != nil {
t.Fatal(e)
}
s := strings.ToUpper(string(b))
for _, x := range []string{"SYSTEM_CONFIG_REVISIONS", "ADMINISTRATORS", "SYSTEM_RESOURCE_BINDINGS", "CONFIG_AUDIT_EVENTS", "PUBLIC_CONFIG"} {
if !strings.Contains(s, x) {
t.Errorf("missing %s", x)
}
}
for _, x := range []string{"CLIENT_SECRET JSON", "ACCESS_TOKEN JSON", "API_KEY JSON"} {
if strings.Contains(s, x) {
t.Errorf("forbidden %s", x)
}
}
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS admin_bootstrap_challenges;
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS admin_bootstrap_challenges (
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, code_hash TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts>=0), max_attempts INTEGER NOT NULL DEFAULT 5 CHECK(max_attempts BETWEEN 1 AND 10),
candidate_staff_id TEXT NOT NULL DEFAULT '', candidate_union_id TEXT NOT NULL DEFAULT '', candidate_display_name TEXT NOT NULL DEFAULT '', candidate_department TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL CHECK(status IN ('pending','candidate_detected','confirmed','expired','locked')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
CREATE UNIQUE INDEX IF NOT EXISTS uq_admin_bootstrap_pending ON admin_bootstrap_challenges(tenant_key) WHERE status IN ('pending','candidate_detected');
@@ -0,0 +1,18 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp013StoresOnlyBootstrapHash(t *testing.T) {
b, e := os.ReadFile("000013_admin_bootstrap.up.sql")
if e != nil {
t.Fatal(e)
}
s := strings.ToUpper(string(b))
if !strings.Contains(s, "CODE_HASH") || strings.Contains(s, " CODE TEXT") {
t.Fatal("bootstrap must store hash only")
}
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS setting_sessions;
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS setting_sessions (
tenant_key TEXT NOT NULL, staff_id TEXT NOT NULL, conversation_id TEXT NOT NULL, conversation_type TEXT NOT NULL CHECK(conversation_type IN ('direct','group')),
current_step TEXT NOT NULL, pending_action TEXT NOT NULL DEFAULT '', draft_config JSONB NOT NULL DEFAULT '{}'::JSONB, expires_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY(tenant_key,staff_id));
CREATE INDEX IF NOT EXISTS idx_setting_sessions_expiry ON setting_sessions(expires_at);
@@ -0,0 +1,20 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp014CreatesExpiringSettingSessions(t *testing.T) {
b, e := os.ReadFile("000014_setting_sessions.up.sql")
if e != nil {
t.Fatal(e)
}
s := strings.ToUpper(string(b))
for _, x := range []string{"SETTING_SESSIONS", "EXPIRES_AT", "DRAFT_CONFIG"} {
if !strings.Contains(s, x) {
t.Errorf("missing %s", x)
}
}
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS user_preferences;
@@ -0,0 +1,3 @@
CREATE TABLE IF NOT EXISTS user_preferences (
tenant_key TEXT NOT NULL, channel TEXT NOT NULL, external_actor_id TEXT NOT NULL, language_mode TEXT NOT NULL DEFAULT 'auto' CHECK(language_mode IN ('auto','fixed')),
locale TEXT NOT NULL DEFAULT 'auto' CHECK(locale IN ('auto','zh-CN','en','ja','ko','es')), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY(tenant_key,channel,external_actor_id));
@@ -0,0 +1,20 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp015ScopesPreferencesByTenantAndActor(t *testing.T) {
b, e := os.ReadFile("000015_user_preferences.up.sql")
if e != nil {
t.Fatal(e)
}
s := strings.ToUpper(string(b))
for _, x := range []string{"USER_PREFERENCES", "TENANT_KEY", "EXTERNAL_ACTOR_ID", "LANGUAGE_MODE", "LOCALE"} {
if !strings.Contains(s, x) {
t.Errorf("missing %s", x)
}
}
}
+69
View File
@@ -0,0 +1,69 @@
package dingtalkconfig
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Result struct {
OK bool
Code string
CheckedAt time.Time
}
type Verifier struct {
endpoint string
client *http.Client
}
func NewVerifier(endpoint string, client *http.Client) *Verifier {
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
return &Verifier{endpoint: strings.TrimSpace(endpoint), client: client}
}
func (v *Verifier) Verify(ctx context.Context, id, secret string) Result {
r := Result{Code: "verification_failed", CheckedAt: time.Now().UTC()}
if strings.TrimSpace(id) == "" || strings.TrimSpace(secret) == "" {
r.Code = "credential_incomplete"
return r
}
u, err := url.Parse(v.endpoint)
if err != nil {
r.Code = "endpoint_invalid"
return r
}
q := u.Query()
q.Set("appkey", id)
q.Set("appsecret", secret)
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return r
}
resp, err := v.client.Do(req)
if err != nil {
r.Code = "network_error"
return r
}
defer resp.Body.Close()
var body struct {
ErrCode int `json:"errcode"`
AccessToken string `json:"access_token"`
}
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body) != nil {
r.Code = "invalid_response"
return r
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || body.ErrCode != 0 || strings.TrimSpace(body.AccessToken) == "" {
r.Code = "credential_rejected"
return r
}
r.OK = true
r.Code = "ok"
return r
}
+35
View File
@@ -0,0 +1,35 @@
package dingtalkconfig
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestVerifierNeverReturnsToken(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"errcode":0,"access_token":"secret-token"}`))
}))
defer ts.Close()
v := NewVerifier(ts.URL, ts.Client())
result := v.Verify(context.Background(), "id", "secret")
if !result.OK {
t.Fatalf("result=%+v", result)
}
if strings.Contains(result.Code, "secret-token") {
t.Fatal("token leaked")
}
}
func TestVerifierRejectsBadCredential(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"errcode":40014,"errmsg":"bad secret"}`))
}))
defer ts.Close()
result := NewVerifier(ts.URL, ts.Client()).Verify(context.Background(), "id", "wrong")
if result.OK || result.Code != "credential_rejected" {
t.Fatalf("result=%+v", result)
}
}
+84
View File
@@ -0,0 +1,84 @@
package secretstore
import (
"crypto/rand"
"encoding/hex"
"errors"
"os"
"path/filepath"
"strings"
)
type Store struct{ root string }
func New(root string) (*Store, error) {
root = strings.TrimSpace(root)
if root == "" {
return nil, errors.New("secret root is required")
}
if err := os.MkdirAll(root, 0700); err != nil {
return nil, err
}
if err := os.Chmod(root, 0700); err != nil {
return nil, err
}
return &Store{root: root}, nil
}
func (s *Store) Put(kind, value string) (string, error) {
kind = strings.TrimSpace(kind)
value = strings.TrimSpace(value)
if kind == "" || value == "" {
return "", errors.New("secret kind and value are required")
}
if strings.ContainsAny(kind, "/\\") || kind == "." || kind == ".." {
return "", errors.New("invalid secret kind")
}
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
ref := kind + "-" + hex.EncodeToString(b)
tmp, err := os.CreateTemp(s.root, ".new-")
if err != nil {
return "", err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if err = tmp.Chmod(0600); err == nil {
_, err = tmp.WriteString(value)
}
if err == nil {
err = tmp.Sync()
}
closeErr := tmp.Close()
if err == nil {
err = closeErr
}
if err != nil {
return "", err
}
if err = os.Rename(tmpName, filepath.Join(s.root, ref)); err != nil {
return "", err
}
return ref, nil
}
func (s *Store) Get(ref string) (string, error) {
if ref == "" || filepath.Base(ref) != ref {
return "", errors.New("invalid secret reference")
}
b, err := os.ReadFile(filepath.Join(s.root, ref))
if err != nil {
return "", err
}
return string(b), nil
}
func (s *Store) Configured(ref string) bool {
_, err := os.Stat(filepath.Join(s.root, filepath.Base(ref)))
return ref != "" && filepath.Base(ref) == ref && err == nil
}
func (s *Store) Delete(ref string) error {
if ref == "" || filepath.Base(ref) != ref {
return errors.New("invalid secret reference")
}
return os.Remove(filepath.Join(s.root, ref))
}
+39
View File
@@ -0,0 +1,39 @@
package secretstore
import (
"os"
"path/filepath"
"testing"
)
func TestFileStoreWritesSecretWithPrivatePermissions(t *testing.T) {
root := t.TempDir()
s, err := New(root)
if err != nil {
t.Fatal(err)
}
ref, err := s.Put("dingtalk", "top-secret")
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(filepath.Join(root, ref))
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 {
t.Fatalf("mode=%o", info.Mode().Perm())
}
value, err := s.Get(ref)
if err != nil || value != "top-secret" {
t.Fatalf("value=%q err=%v", value, err)
}
}
func TestFileStoreRejectsTraversalAndBlank(t *testing.T) {
s, _ := New(t.TempDir())
if _, err := s.Put("../bad", "x"); err == nil {
t.Fatal("expected traversal rejection")
}
if _, err := s.Put("ok", ""); err == nil {
t.Fatal("expected blank rejection")
}
}
+118
View File
@@ -0,0 +1,118 @@
package settings
import (
"context"
"errors"
"net/url"
"path"
"strings"
)
type SystemState string
const (
StateUninitialized SystemState = "uninitialized"
StateCredentialsSaved SystemState = "credentials_saved"
StateDingTalkVerified SystemState = "dingtalk_verified"
StateWaitingAdmin SystemState = "waiting_first_admin"
StateActive SystemState = "active"
StateCredentialError SystemState = "credential_error"
)
type Snapshot struct {
ClientID string
SecretConfigured bool
CredentialsVerified bool
AdministratorCount int
}
func DetermineState(s Snapshot) SystemState {
id := strings.TrimSpace(s.ClientID) != ""
if !id && !s.SecretConfigured {
return StateUninitialized
}
if !id || !s.SecretConfigured {
return StateCredentialError
}
if !s.CredentialsVerified {
return StateCredentialsSaved
}
if s.AdministratorCount < 1 {
return StateWaitingAdmin
}
return StateActive
}
type Command struct {
Name string
Args []string
}
func ParseCommand(raw string) (Command, bool) {
fields := strings.Fields(strings.TrimSpace(raw))
if len(fields) == 0 {
return Command{}, false
}
name := strings.ToLower(fields[0])
switch name {
case "/setting", "/settings", "/设置":
default:
return Command{}, false
}
return Command{Name: name, Args: fields[1:]}, true
}
type NotableLink struct{ BaseID, SheetID, ViewID string }
func ParseNotableLink(raw string) (NotableLink, error) {
if len(raw) > 4096 {
return NotableLink{}, errors.New("AI Table link is too long")
}
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Scheme != "https" {
return NotableLink{}, errors.New("invalid AI Table link")
}
host := strings.ToLower(u.Hostname())
if host != "alidocs.dingtalk.com" && host != "docs.dingtalk.com" {
return NotableLink{}, errors.New("untrusted AI Table host")
}
base := path.Base(strings.TrimRight(u.Path, "/"))
q := u.Query()
result := NotableLink{BaseID: base, SheetID: q.Get("sheetId"), ViewID: q.Get("viewId")}
if result.BaseID == "" || result.BaseID == "." || result.SheetID == "" {
return NotableLink{}, errors.New("AI Table link is missing base or sheet")
}
return result, nil
}
type Runtime interface {
Start(context.Context, string) error
Stop(context.Context) error
}
type Switcher struct {
runtime Runtime
active string
}
func NewSwitcher(r Runtime, active string) *Switcher { return &Switcher{runtime: r, active: active} }
func (s *Switcher) Switch(ctx context.Context, candidate string) error {
if s == nil || s.runtime == nil {
return errors.New("runtime is required")
}
candidate = strings.TrimSpace(candidate)
if candidate == "" {
return errors.New("candidate is required")
}
old := s.active
if err := s.runtime.Stop(ctx); err != nil {
return err
}
if err := s.runtime.Start(ctx, candidate); err != nil {
if old != "" {
_ = s.runtime.Start(ctx, old)
}
return err
}
s.active = candidate
return nil
}
+85
View File
@@ -0,0 +1,85 @@
package settings
import (
"context"
"errors"
"testing"
)
func TestDetermineState(t *testing.T) {
cases := []struct {
name string
in Snapshot
want SystemState
}{
{"empty", Snapshot{}, StateUninitialized},
{"partial", Snapshot{ClientID: "id"}, StateCredentialError},
{"saved", Snapshot{ClientID: "id", SecretConfigured: true}, StateCredentialsSaved},
{"verified", Snapshot{ClientID: "id", SecretConfigured: true, CredentialsVerified: true}, StateWaitingAdmin},
{"active", Snapshot{ClientID: "id", SecretConfigured: true, CredentialsVerified: true, AdministratorCount: 1}, StateActive},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := DetermineState(tc.in); got != tc.want {
t.Fatalf("got %s want %s", got, tc.want)
}
})
}
}
func TestParseCommandStrict(t *testing.T) {
yes := []string{"/setting", "/settings", "/设置", "/setting 123456", "/setting language ja", "/setting support", "/setting question https://alidocs.dingtalk.com/i/nodes/abc"}
for _, raw := range yes {
if c, ok := ParseCommand(raw); !ok || c.Name == "" {
t.Fatalf("must match %q", raw)
}
}
no := []string{"如何设置机器人", "abc /setting", "请解释/setting", "/settiing"}
for _, raw := range no {
if _, ok := ParseCommand(raw); ok {
t.Fatalf("must not match %q", raw)
}
}
}
func TestParseNotableLink(t *testing.T) {
got, err := ParseNotableLink("https://alidocs.dingtalk.com/i/nodes/base123?sheetId=sheet456&viewId=view789&recordId=row")
if err != nil {
t.Fatal(err)
}
if got.BaseID != "base123" || got.SheetID != "sheet456" || got.ViewID != "view789" {
t.Fatalf("got %+v", got)
}
if _, err = ParseNotableLink("http://evil.example/x?sheetId=s"); err == nil {
t.Fatal("expected rejection")
}
}
type fakeRuntime struct {
active string
starts []string
fail string
}
func (f *fakeRuntime) Start(_ context.Context, id string) error {
f.starts = append(f.starts, id)
if id == f.fail {
return errors.New("boom")
}
f.active = id
return nil
}
func (f *fakeRuntime) Stop(_ context.Context) error { f.active = ""; return nil }
func TestSwitcherRollsBack(t *testing.T) {
f := &fakeRuntime{active: "old", fail: "new"}
s := NewSwitcher(f, "old")
if err := s.Switch(context.Background(), "new"); err == nil {
t.Fatal("expected error")
}
if f.active != "old" {
t.Fatalf("active=%q", f.active)
}
if len(f.starts) != 2 || f.starts[0] != "new" || f.starts[1] != "old" {
t.Fatalf("starts=%v", f.starts)
}
}