feat: let administrators view and copy encrypted integration credentials
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
type Config struct {
|
||||
ConsoleApplicationAdmin string // ConsoleApplicationAdmin 可跨应用管理接入的唯一控制台登录名;空时禁用。
|
||||
CredentialKeyFile string // CredentialKeyFile 管理员 Token 保管密钥文件路径;所有服务节点共用同一密钥环。
|
||||
ArchiveRoot string // ArchiveRoot 私有持久归档卷绝对路径,空表示关闭后台执行器。
|
||||
ArchiveStorageID core.ID // ArchiveStorageID 32位小写十六进制卷身份,各节点须挂载同一物理卷。
|
||||
ArchiveMaxBytes int64 // ArchiveMaxBytes 全卷文件预算,默认10GiB。
|
||||
@@ -40,6 +41,7 @@ func Load() (Config, error) { return FromEnv(os.Getenv) }
|
||||
func FromEnv(get func(string) string) (Config, error) {
|
||||
c := Config{
|
||||
ConsoleApplicationAdmin: get("ORBIT_CONSOLE_APPLICATION_ADMIN"),
|
||||
CredentialKeyFile: get("ORBIT_CREDENTIAL_KEY_FILE"),
|
||||
ArchiveRoot: get("ORBIT_ARCHIVE_ROOT"), ArchiveStorageID: core.ID(get("ORBIT_ARCHIVE_STORAGE_ID")), ArchiveMaxBytes: 10 << 30, ArchiveTickInterval: 10 * time.Second,
|
||||
HTTPAddr: get("ORBIT_HTTP_ADDR"),
|
||||
MySQLDSN: get("ORBIT_MYSQL_DSN"),
|
||||
|
||||
@@ -32,6 +32,7 @@ type AccessQueue struct {
|
||||
|
||||
// AccessCredential 是脱敏的凭据元数据,绝不返回秘密或秘密摘要。
|
||||
type AccessCredential struct {
|
||||
TokenStored bool `json:"token_stored"` // TokenStored 当前 Token 是否已加密保存;列表不会返回秘密。
|
||||
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"` // PreviousValidUntil 上一代令牌实际截止,空表示无旧令牌。
|
||||
ID core.ID `json:"id"` // ID 凭据标识,重新生成 Token 时不改变。
|
||||
Name string `json:"name"` // Name 凭据名称。
|
||||
@@ -43,10 +44,11 @@ type AccessCredential struct {
|
||||
|
||||
// ApplicationDetail 展示一个命名空间的队列和凭据,读取量有界。
|
||||
type ApplicationDetail struct {
|
||||
Entry ApplicationEntry `json:"entry"` // Entry 当前应用与环境。
|
||||
Queues []AccessQueue `json:"queues"` // Queues 最多 100 个队列。
|
||||
Credentials []AccessCredential `json:"credentials"` // Credentials 最多 100 张凭据。
|
||||
Truncated bool `json:"truncated"` // Truncated 为真时不得把当前结果解释为完整清单。
|
||||
TokenVaultEnabled bool `json:"token_vault_enabled"` // TokenVaultEnabled 当前节点是否配置了凭据保管密钥。
|
||||
Entry ApplicationEntry `json:"entry"` // Entry 当前应用与环境。
|
||||
Queues []AccessQueue `json:"queues"` // Queues 最多 100 个队列。
|
||||
Credentials []AccessCredential `json:"credentials"` // Credentials 最多 100 张凭据。
|
||||
Truncated bool `json:"truncated"` // Truncated 为真时不得把当前结果解释为完整清单。
|
||||
}
|
||||
|
||||
// ApplicationInput 描述创建应用或变更凭据的管理员意图。
|
||||
@@ -65,7 +67,7 @@ type ApplicationInput struct {
|
||||
Reason string `json:"reason"` // Reason 写入审计的变更原因。
|
||||
}
|
||||
|
||||
// ApplicationOperation 是浏览器在提交前保存的固定操作;Token 不写入数据库。
|
||||
// ApplicationOperation 是浏览器在提交前保存的固定操作;启用保管时仅保存 Token 密文。
|
||||
// 网络结果未知时重放同一份内容,禁止重新生成操作身份或秘密。
|
||||
type ApplicationOperation struct {
|
||||
Version int `json:"version,omitempty"` // Version v2 支持长期令牌与过渡;旧固定操作未设置时沿用 v1。
|
||||
@@ -75,7 +77,7 @@ type ApplicationOperation struct {
|
||||
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 固定命名空间标识。
|
||||
QueueID core.ID `json:"queue_id,omitempty"` // QueueID 新建应用的初始队列。
|
||||
CredentialID core.ID `json:"credential_id"` // CredentialID 固定凭据身份。
|
||||
Token string `json:"token,omitempty"` // Token 一次性交付的秘密,只留在调用方内存或私密下载文件。
|
||||
Token string `json:"token,omitempty"` // Token 当前秘密;服务端启用保管后加密保存,不能进入日志。
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"` // ExpiresAt 固定到期时间,空表示长期或不适用。
|
||||
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"` // PreviousValidUntil ROTATE 固定过渡截止。
|
||||
}
|
||||
|
||||
33
internal/consoleauth/credential_tokens.go
Normal file
33
internal/consoleauth/credential_tokens.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package consoleauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
)
|
||||
|
||||
// CredentialTokenInput 用于管理员查看或补存已有 Token,不改变业务认证身份。
|
||||
type CredentialTokenInput struct {
|
||||
ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 页面读取到的凭据版本;并发换证时拒绝返回混合版本。
|
||||
Token string `json:"token,omitempty"` // Token 仅导入时填写;必须与当前摘要一致,旧代令牌不可导入。
|
||||
}
|
||||
|
||||
func (CredentialTokenInput) String() string { return "CredentialTokenInput{REDACTED}" }
|
||||
func (v CredentialTokenInput) GoString() string { return v.String() }
|
||||
|
||||
// CredentialToken 只在已认证且已提交查看审计的管理员专用请求中返回。
|
||||
type CredentialToken struct {
|
||||
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 当前凭据的命名空间。
|
||||
CredentialID core.ID `json:"credential_id"` // CredentialID 当前凭据标识。
|
||||
Revision uint64 `json:"revision,string"` // Revision 读取时的当前凭据版本。
|
||||
Token string `json:"token"` // Token 当前代次原文,禁止进入日志、列表或浏览器持久存储。
|
||||
}
|
||||
|
||||
func (CredentialToken) String() string { return "CredentialToken{REDACTED}" }
|
||||
func (v CredentialToken) GoString() string { return v.String() }
|
||||
|
||||
// CredentialTokens 的 importCurrent 为真时补存原文;为假时只读取,两个操作均留审计。
|
||||
type CredentialTokens interface {
|
||||
AccessCredentialToken(context.Context, auth.Token, core.ID, core.ID, CredentialTokenInput, bool) (CredentialToken, error)
|
||||
}
|
||||
111
internal/credentialvault/vault.go
Normal file
111
internal/credentialvault/vault.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// Package credentialvault 加密保存管理员可重新查看的当前业务 Token。
|
||||
// 主密钥来自独立文件,不进入数据库、日志或 API 响应。
|
||||
package credentialvault
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
)
|
||||
|
||||
// Keyring 为不可变密钥环;active 用于新写入,历史密钥用于解密既有记录。
|
||||
type Keyring struct {
|
||||
active string
|
||||
keys map[string]cipher.AEAD
|
||||
}
|
||||
|
||||
func (*Keyring) String() string { return "credentialvault.Keyring{REDACTED}" }
|
||||
func (k *Keyring) GoString() string { return k.String() }
|
||||
|
||||
// LoadFile 读取最多 64 KiB 的 JSON 密钥文件。空路径表示未启用密文保管。
|
||||
func LoadFile(path string) (*Keyring, error) {
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, errors.New("cannot open credential key file")
|
||||
}
|
||||
defer f.Close()
|
||||
return Read(io.LimitReader(f, 65537))
|
||||
}
|
||||
|
||||
// Read 校验 active 和 keys;每个 Base64 密钥必须恰好为 32 字节。
|
||||
func Read(r io.Reader) (*Keyring, error) {
|
||||
var config struct {
|
||||
Active string `json:"active"`
|
||||
Keys map[string]string `json:"keys"`
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(r, 65537))
|
||||
if err != nil || len(raw) > 65536 {
|
||||
return nil, errors.New("invalid credential key file")
|
||||
}
|
||||
if err = json.Unmarshal(raw, &config); err != nil || len(config.Keys) == 0 || len(config.Keys) > 16 {
|
||||
return nil, errors.New("invalid credential key file")
|
||||
}
|
||||
out := &Keyring{active: config.Active, keys: make(map[string]cipher.AEAD)}
|
||||
for id, encoded := range config.Keys {
|
||||
key, err := base64.StdEncoding.Strict().DecodeString(encoded)
|
||||
if err != nil || len(key) != 32 || !regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`).MatchString(id) {
|
||||
return nil, errors.New("invalid credential encryption key")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid credential encryption key")
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, errors.New("cannot initialize credential encryption")
|
||||
}
|
||||
out.keys[id] = aead
|
||||
}
|
||||
if out.keys[out.active] == nil {
|
||||
return nil, errors.New("active credential encryption key is missing")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func aad(namespace, credential core.ID) []byte {
|
||||
return []byte("orbit-credential-v1:" + string(namespace) + ":" + string(credential))
|
||||
}
|
||||
|
||||
// Seal 返回密钥标识和 nonce+ciphertext。未启用时两者均为空,兼容只存摘要的管理 CLI。
|
||||
func (k *Keyring) Seal(namespace, credential core.ID, token string) (*string, []byte, error) {
|
||||
if k == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
if namespace.Validate() != nil || credential.Validate() != nil || len(token) == 0 || len(token) > 512 {
|
||||
return nil, nil, errors.New("invalid credential encryption input")
|
||||
}
|
||||
aead := k.keys[k.active]
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, nil, errors.New("cannot generate credential encryption nonce")
|
||||
}
|
||||
id := k.active
|
||||
return &id, aead.Seal(nonce, nonce, []byte(token), aad(namespace, credential)), nil
|
||||
}
|
||||
|
||||
// Open 只解密指定凭据;调用方必须先认证管理员、校验版本,再核对当前摘要并记录审计。
|
||||
func (k *Keyring) Open(namespace, credential core.ID, keyID string, ciphertext []byte) (string, error) {
|
||||
if k == nil {
|
||||
return "", errors.New("credential encryption is not configured")
|
||||
}
|
||||
aead := k.keys[keyID]
|
||||
if aead == nil || len(ciphertext) < aead.NonceSize()+aead.Overhead() || len(ciphertext) > 1024 {
|
||||
return "", errors.New("credential ciphertext cannot be decrypted")
|
||||
}
|
||||
raw, err := aead.Open(nil, ciphertext[:aead.NonceSize()], ciphertext[aead.NonceSize():], aad(namespace, credential))
|
||||
if err != nil {
|
||||
return "", errors.New("credential ciphertext cannot be decrypted")
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
80
internal/credentialvault/vault_test.go
Normal file
80
internal/credentialvault/vault_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package credentialvault
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
)
|
||||
|
||||
func TestCredentialEncryption(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))
|
||||
ring, err := Read(strings.NewReader(`{"active":"one","keys":{"one":"` + key + `"}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ns, _ := core.NewID()
|
||||
id, _ := core.NewID()
|
||||
other, _ := core.NewID()
|
||||
k, a, err := ring.Seal(ns, id, "test-only-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, b, _ := ring.Seal(ns, id, "test-only-secret")
|
||||
if bytes.Equal(a, b) || bytes.Contains(a, []byte("test-only-secret")) {
|
||||
t.Fatal("nonce reused or plaintext stored")
|
||||
}
|
||||
got, err := ring.Open(ns, id, *k, a)
|
||||
if err != nil || got != "test-only-secret" {
|
||||
t.Fatal("round trip failed")
|
||||
}
|
||||
if _, err = ring.Open(ns, other, *k, a); err == nil {
|
||||
t.Fatal("different credential accepted")
|
||||
}
|
||||
if _, err = ring.Open(other, id, *k, a); err == nil {
|
||||
t.Fatal("different namespace accepted")
|
||||
}
|
||||
if _, err = ring.Open(ns, id, "unknown", a); err == nil {
|
||||
t.Fatal("unknown key accepted")
|
||||
}
|
||||
a[len(a)-1] ^= 1
|
||||
if _, err = ring.Open(ns, id, *k, a); err == nil {
|
||||
t.Fatal("tampered ciphertext accepted")
|
||||
}
|
||||
for _, v := range []string{fmt.Sprint(ring), fmt.Sprintf("%#v", ring)} {
|
||||
if strings.Contains(v, key) || !strings.Contains(v, "REDACTED") {
|
||||
t.Fatal("keyring formatting not redacted")
|
||||
}
|
||||
}
|
||||
old, _ := Read(strings.NewReader(`{"active":"one","keys":{"one":"` + key + `"}}`))
|
||||
k, c, _ := old.Seal(ns, id, "old-secret")
|
||||
newKey := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{8}, 32))
|
||||
rotated, err := Read(strings.NewReader(`{"active":"two","keys":{"one":"` + key + `","two":"` + newKey + `"}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, err := rotated.Open(ns, id, *k, c); err != nil || v != "old-secret" {
|
||||
t.Fatal("retained key cannot read old data")
|
||||
}
|
||||
if k, _, _ := rotated.Seal(ns, id, "new-secret"); *k != "two" {
|
||||
t.Fatal("active key ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialKeyValidation(t *testing.T) {
|
||||
for _, raw := range []string{`{}`, `{"active":"x","keys":{"x":"bad"}}`, `{"active":"missing","keys":{"x":"` + base64.StdEncoding.EncodeToString(make([]byte, 32)) + `"}}`, strings.Repeat(" ", 65537)} {
|
||||
if _, err := Read(strings.NewReader(raw)); err == nil {
|
||||
t.Fatal("invalid key configuration accepted")
|
||||
}
|
||||
}
|
||||
k, err := LoadFile("")
|
||||
if err != nil || k != nil {
|
||||
t.Fatal("empty path must disable vault")
|
||||
}
|
||||
if id, c, err := k.Seal("", "", ""); err != nil || id != nil || c != nil {
|
||||
t.Fatal("disabled vault must use SQL NULL pair")
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,7 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
|
||||
// 已经存在的 v29/v30 表误当成迁移重复执行。
|
||||
f.exec(`DROP TABLE orbit_archive_jobs,orbit_archive_plans,orbit_retention_policies,orbit_console_sessions,orbit_console_users`)
|
||||
f.exec(`ALTER TABLE orbit_credentials DROP CHECK ck_credential_overlap, DROP COLUMN previous_secret_digest, DROP COLUMN previous_valid_until`)
|
||||
f.exec(`ALTER TABLE orbit_credentials DROP CHECK ck_credential_ciphertext, DROP COLUMN secret_key_id, DROP COLUMN secret_ciphertext`)
|
||||
f.exec(`DELETE FROM orbit_schema_migrations WHERE version>=10`)
|
||||
if err := Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Orbit fresh-install baseline, generated from migration version 32.
|
||||
-- Orbit fresh-install baseline, generated from migration version 33.
|
||||
-- This file intentionally contains no database-level relation constraints.
|
||||
CREATE TABLE `orbit_application_resources` (
|
||||
`application_id` binary(16) NOT NULL COMMENT '所属应用标识及主键,跨命名空间共享额度',
|
||||
@@ -59,6 +59,8 @@ CREATE TABLE `orbit_credentials` (
|
||||
`updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`name` varchar(128) COLLATE utf8mb4_0900_as_cs NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束',
|
||||
`secret_digest` binary(32) NOT NULL COMMENT '凭据秘密的 SHA-256 摘要,不保存或输出原文',
|
||||
`secret_key_id` varchar(64) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL COMMENT '当前 Token 密文使用的主密钥标识;空表示尚未保管,主密钥不存数据库',
|
||||
`secret_ciphertext` varbinary(1024) DEFAULT NULL COMMENT '当前 Token 的 AES-256-GCM nonce 与认证密文;空表示旧凭据仅存摘要',
|
||||
`previous_secret_digest` binary(32) DEFAULT NULL COMMENT '上一代秘密的 SHA-256 摘要;为空表示无过渡令牌,不保存原文',
|
||||
`previous_valid_until` datetime(6) DEFAULT NULL COMMENT '上一代令牌过渡截止,UTC;不晚于其原到期时间,与旧摘要同时为空或非空',
|
||||
`permissions` json NOT NULL COMMENT '授权范围的 JSON 快照,必须由服务端解释和校验',
|
||||
@@ -67,8 +69,9 @@ CREATE TABLE `orbit_credentials` (
|
||||
PRIMARY KEY (`namespace_id`,`id`),
|
||||
UNIQUE KEY `uq_credential_name` (`namespace_id`,`name`),
|
||||
CONSTRAINT `ck_orbit_credentials_0` CHECK ((`revision` > 0)),
|
||||
CONSTRAINT `ck_credential_ciphertext` CHECK ((secret_key_id IS NULL AND secret_ciphertext IS NULL) OR (secret_key_id IS NOT NULL AND secret_ciphertext IS NOT NULL AND LENGTH(secret_key_id)>0 AND LENGTH(secret_ciphertext)>=28)),
|
||||
CONSTRAINT `ck_credential_overlap` CHECK ((previous_secret_digest IS NULL AND previous_valid_until IS NULL) OR (previous_secret_digest IS NOT NULL AND previous_valid_until IS NOT NULL))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='命名空间凭据,只保存秘密摘要和授权范围';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='命名空间凭据、认证摘要、授权范围及可选的当前 Token 密文';
|
||||
CREATE TABLE `orbit_deliveries` (
|
||||
`namespace_id` binary(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
`id` binary(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/credentialvault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
@@ -49,7 +50,11 @@ type BootstrapResult struct {
|
||||
ConsoleUsername string `json:"console_username,omitempty"` // ConsoleUsername 可选的首个控制台账号,不包含密码。
|
||||
}
|
||||
|
||||
func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(BootstrapResult) error) error {
|
||||
func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(BootstrapResult) error, vault ...*credentialvault.Keyring) error {
|
||||
var ring *credentialvault.Keyring
|
||||
if len(vault) > 0 {
|
||||
ring = vault[0]
|
||||
}
|
||||
if in.ValidFor != 0 && (in.ValidFor < time.Hour || in.ValidFor > 365*24*time.Hour) {
|
||||
return fmt.Errorf("validity must be zero or between one hour and one year")
|
||||
}
|
||||
@@ -96,6 +101,10 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
|
||||
return err
|
||||
}
|
||||
out.Token = secret
|
||||
keyID, ciphertext, err := ring.Seal(out.NamespaceID, out.CredentialID, secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
permissions := auth.Permissions{Actions: []string{"tasks:submit", "tasks:read", "tasks:cancel", "queues:read", "queues:manage"}, Queues: []string{"*"}}
|
||||
if in.WorkerAccess {
|
||||
permissions.Actions = append(permissions.Actions, "workers:connect", "workers:consume")
|
||||
@@ -180,7 +189,7 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
|
||||
if _, err = tx.ExecContext(ctx, "INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,4,10,'{}')", out.NamespaceID, out.QueueID, in.Queue); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at) VALUES (?,?,'bootstrap',?,?,?)", out.NamespaceID, out.CredentialID, digest, string(raw), out.ExpiresAt); err != nil {
|
||||
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at,secret_key_id,secret_ciphertext) VALUES (?,?,'bootstrap',?,?,?,?,?)", out.NamespaceID, out.CredentialID, digest, string(raw), out.ExpiresAt, keyID, ciphertext); err != nil {
|
||||
return err
|
||||
}
|
||||
actor, err := databaseActor(ctx, tx)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/credentialvault"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
@@ -21,10 +22,15 @@ import (
|
||||
type ConsoleApplicationStore struct {
|
||||
db *sql.DB
|
||||
username string
|
||||
vault *credentialvault.Keyring
|
||||
}
|
||||
|
||||
func NewConsoleApplicationStore(db *sql.DB, username string) *ConsoleApplicationStore {
|
||||
return &ConsoleApplicationStore{db: db, username: username}
|
||||
func NewConsoleApplicationStore(db *sql.DB, username string, vault ...*credentialvault.Keyring) *ConsoleApplicationStore {
|
||||
s := &ConsoleApplicationStore{db: db, username: username}
|
||||
if len(vault) > 0 {
|
||||
s.vault = vault[0]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var _ consoleauth.Applications = (*ConsoleApplicationStore)(nil)
|
||||
@@ -32,7 +38,7 @@ var _ consoleauth.Applications = (*ConsoleApplicationStore)(nil)
|
||||
// admin 在实际读写事务中锁定人类账号及会话;普通 SDK Token 无对应会话,无法通过。
|
||||
// 不锁业务凭据,轮换当前账号所绑定的 SDK 凭据也不会产生共享锁升级。
|
||||
func (s *ConsoleApplicationStore) admin(ctx context.Context, tx *sql.Tx, token auth.Token) (string, error) {
|
||||
if s.username == "" {
|
||||
if s.username == "" || token.NamespaceID.Validate() != nil || token.CredentialID.Validate() != nil {
|
||||
return "", fault.Forbidden
|
||||
}
|
||||
var user core.ID
|
||||
@@ -117,7 +123,8 @@ func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err = tx.QueryContext(ctx, `SELECT id,name,revision,expires_at,revoked_at,previous_valid_until,permissions FROM orbit_credentials WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace)
|
||||
out.TokenVaultEnabled = s.vault != nil
|
||||
rows, err = tx.QueryContext(ctx, `SELECT id,name,revision,expires_at,revoked_at,previous_valid_until,permissions,secret_ciphertext IS NOT NULL FROM orbit_credentials WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -126,7 +133,7 @@ func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth
|
||||
var c consoleauth.AccessCredential
|
||||
var expires, revoked, previous sql.NullTime
|
||||
var raw []byte
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Revision, &expires, &revoked, &previous, &raw); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Revision, &expires, &revoked, &previous, &raw, &c.TokenStored); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(raw, &c.Permissions); err != nil {
|
||||
@@ -299,7 +306,7 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au
|
||||
return "", fault.Invalid("application identity mismatch")
|
||||
}
|
||||
return actor, nil
|
||||
})
|
||||
}, s.vault)
|
||||
return consoleauth.ApplicationReceipt{OperationID: op.OperationID, ApplicationID: op.ApplicationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, Revision: receipt.Revision, Deduplicated: receipt.Deduplicated}, err
|
||||
}
|
||||
parsed, err := auth.Parse(op.Token)
|
||||
@@ -352,6 +359,10 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au
|
||||
if op.ExpiresAt != nil && (!op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour))) {
|
||||
return fault.Invalid("expired or excessive credential lifetime")
|
||||
}
|
||||
keyID, ciphertext, err := s.vault.Seal(op.NamespaceID, op.CredentialID, op.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
statements := []struct {
|
||||
query string
|
||||
args []any
|
||||
@@ -360,7 +371,7 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au
|
||||
{`INSERT INTO orbit_application_resources(application_id) VALUES (?)`, []any{op.ApplicationID}},
|
||||
{`INSERT INTO orbit_namespaces(id,application_id,name,environment) VALUES (?,?,?,?)`, []any{op.NamespaceID, op.ApplicationID, op.Input.Namespace, op.Input.Environment}},
|
||||
{`INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,4,10,'{}')`, []any{op.NamespaceID, op.QueueID, op.Input.Queue}},
|
||||
{`INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at) VALUES (?,?,'console-issued',?,?,?)`, []any{op.NamespaceID, op.CredentialID, parsed.Digest, string(raw), op.ExpiresAt}},
|
||||
{`INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at,secret_key_id,secret_ciphertext) VALUES (?,?,'console-issued',?,?,?,?,?)`, []any{op.NamespaceID, op.CredentialID, parsed.Digest, string(raw), op.ExpiresAt, keyID, ciphertext}},
|
||||
}
|
||||
for _, st := range statements {
|
||||
if _, err := tx.ExecContext(ctx, st.query, st.args...); err != nil {
|
||||
|
||||
85
internal/storage/mysql/console_credential_tokens.go
Normal file
85
internal/storage/mysql/console_credential_tokens.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
)
|
||||
|
||||
var _ consoleauth.CredentialTokens = (*ConsoleApplicationStore)(nil)
|
||||
|
||||
// AccessCredentialToken 认证、版本核对、密文读取/补存和审计在同一事务完成。
|
||||
// 导入相同当前秘密可重复调用,不变更认证 revision、到期时间或旧代过渡截止。
|
||||
func (s *ConsoleApplicationStore) AccessCredentialToken(ctx context.Context, session auth.Token, namespace, credential core.ID, in consoleauth.CredentialTokenInput, importCurrent bool) (consoleauth.CredentialToken, error) {
|
||||
var out consoleauth.CredentialToken
|
||||
if namespace.Validate() != nil || credential.Validate() != nil || in.ExpectedRevision == 0 || (!importCurrent && in.Token != "") {
|
||||
return out, fault.Invalid("invalid credential token request")
|
||||
}
|
||||
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
actor, err := s.admin(ctx, tx, session)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.vault == nil {
|
||||
return fault.New("UNAVAILABLE", "Token 保管密钥尚未配置,请联系部署管理员")
|
||||
}
|
||||
var revision uint64
|
||||
var revoked sql.NullTime
|
||||
var digest core.Digest
|
||||
var keyID sql.NullString
|
||||
var ciphertext []byte
|
||||
err = tx.QueryRowContext(ctx, `SELECT revision,revoked_at,secret_digest,secret_key_id,secret_ciphertext FROM orbit_credentials WHERE namespace_id=? AND id=? FOR UPDATE`, namespace, credential).Scan(&revision, &revoked, &digest, &keyID, &ciphertext)
|
||||
if err == sql.ErrNoRows {
|
||||
return fault.NotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if revision != in.ExpectedRevision || revoked.Valid {
|
||||
return fault.New("STATE_CONFLICT", "凭据已变更或撤销,请刷新后重试")
|
||||
}
|
||||
raw := in.Token
|
||||
action := "console.credential.token.view"
|
||||
if !importCurrent {
|
||||
if !keyID.Valid {
|
||||
return fault.New("STATE_CONFLICT", "此历史凭据尚未保管 Token,请先导入当前 Token")
|
||||
}
|
||||
raw, err = s.vault.Open(namespace, credential, keyID.String, ciphertext)
|
||||
if err != nil {
|
||||
return fault.New("UNAVAILABLE", "Token 解密失败,请检查保管密钥文件")
|
||||
}
|
||||
}
|
||||
parsed, err := auth.Parse(raw)
|
||||
if err != nil || parsed.NamespaceID != namespace || parsed.CredentialID != credential || subtleCompareDigest(parsed.Digest, digest) != 1 {
|
||||
return fault.Invalid("Token 与当前凭据不匹配;不能导入过渡期旧 Token")
|
||||
}
|
||||
if importCurrent {
|
||||
action = "console.credential.token.import"
|
||||
id, encrypted, err := s.vault.Seal(namespace, credential, raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET secret_key_id=?,secret_ciphertext=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, id, encrypted, namespace, credential); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
operation, err := core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appendAuditActor(ctx, tx, namespace, actor, operation, action, credential, "administrator access to current integration configuration", nil, map[string]any{"revision": revision}); err != nil {
|
||||
return err
|
||||
}
|
||||
out = consoleauth.CredentialToken{NamespaceID: namespace, CredentialID: credential, Revision: revision, Token: raw}
|
||||
return nil
|
||||
})
|
||||
// 包括提交结果未知:只有确认审计提交成功,才允许向调用方交付秘密。
|
||||
if err != nil {
|
||||
return consoleauth.CredentialToken{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
207
internal/storage/mysql/credential_vault_integration_test.go
Normal file
207
internal/storage/mysql/credential_vault_integration_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/credentialvault"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
)
|
||||
|
||||
func TestIntegrationCredentialVault(t *testing.T) {
|
||||
ctx, db := overlapDB(t)
|
||||
ring, err := credentialvault.Read(strings.NewReader(`{"active":"test","keys":{"test":"` + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{42}, 32)) + `"}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := "vault-" + string(testID(t))
|
||||
var initial BootstrapResult
|
||||
if err := Bootstrap(ctx, db, BootstrapInput{Application: name, Namespace: "default", Environment: "test", Queue: "work", ConsoleUsername: name, ConsolePassword: "vault-test-password-12345"}, func(v BootstrapResult) error { initial = v; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
login, err := NewConsoleAuthStore(db).Login(ctx, name, "vault-test-password-12345", "test", "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin, _ := auth.Parse(login.Token)
|
||||
sdk, _ := auth.Parse(initial.Token)
|
||||
store := NewConsoleApplicationStore(db, name, ring)
|
||||
in := consoleauth.CredentialTokenInput{ExpectedRevision: 1}
|
||||
access := func(input consoleauth.CredentialTokenInput, importCurrent bool) (consoleauth.CredentialToken, error) {
|
||||
return store.AccessCredentialToken(ctx, admin, initial.NamespaceID, initial.CredentialID, input, importCurrent)
|
||||
}
|
||||
for _, unauthorized := range []auth.Token{sdk, {}} {
|
||||
if _, err := store.AccessCredentialToken(ctx, unauthorized, initial.NamespaceID, initial.CredentialID, in, false); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatal("SDK or missing session accepted")
|
||||
}
|
||||
}
|
||||
if _, err := NewConsoleApplicationStore(db, "other", ring).AccessCredentialToken(ctx, admin, initial.NamespaceID, initial.CredentialID, in, false); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatal("wrong administrator accepted")
|
||||
}
|
||||
if _, err := access(in, false); fault.Code(err) != "STATE_CONFLICT" {
|
||||
t.Fatal("legacy token unexpectedly revealed")
|
||||
}
|
||||
in.Token = initial.Token
|
||||
// 审计失败时导入事务回滚,查看也不能把秘密交付给调用方。
|
||||
trigger := "vault_audit_" + string(testID(t))
|
||||
if _, err := db.ExecContext(ctx, "CREATE TRIGGER "+trigger+" BEFORE INSERT ON orbit_audit_events FOR EACH ROW BEGIN IF NEW.namespace_id=UNHEX('"+string(initial.NamespaceID)+"') AND NEW.action LIKE 'console.credential.token.%' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='vault audit failure test'; END IF; END"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.ExecContext(ctx, "DROP TRIGGER IF EXISTS "+trigger)
|
||||
if got, err := access(in, true); err == nil || got.Token != "" {
|
||||
t.Fatal("audit failure returned secret")
|
||||
}
|
||||
var imported bool
|
||||
if err := db.QueryRowContext(ctx, `SELECT secret_ciphertext IS NOT NULL FROM orbit_credentials WHERE namespace_id=? AND id=?`, initial.NamespaceID, initial.CredentialID).Scan(&imported); err != nil || imported {
|
||||
t.Fatal("failed import did not roll back")
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "DROP TRIGGER "+trigger); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := access(in, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := access(in, true); err != nil {
|
||||
t.Fatal("repeat import failed", err)
|
||||
}
|
||||
in.Token = ""
|
||||
if _, err := db.ExecContext(ctx, "CREATE TRIGGER "+trigger+" BEFORE INSERT ON orbit_audit_events FOR EACH ROW BEGIN IF NEW.namespace_id=UNHEX('"+string(initial.NamespaceID)+"') AND NEW.action='console.credential.token.view' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='vault audit failure test'; END IF; END"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := access(in, false); err == nil || got.Token != "" {
|
||||
t.Fatal("read without audit returned secret")
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "DROP TRIGGER "+trigger); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := access(in, false); err != nil || got.Token != initial.Token || got.Revision != 1 {
|
||||
t.Fatal("import changed identity or cannot reveal")
|
||||
}
|
||||
var cipher []byte
|
||||
if err := db.QueryRowContext(ctx, `SELECT secret_ciphertext FROM orbit_credentials WHERE namespace_id=? AND id=?`, initial.NamespaceID, initial.CredentialID).Scan(&cipher); err != nil || bytes.Contains(cipher, []byte(initial.Token)) {
|
||||
t.Fatal("plaintext persisted")
|
||||
}
|
||||
detail, err := store.GetApplication(ctx, admin, initial.NamespaceID)
|
||||
raw, _ := json.Marshal(detail)
|
||||
if err != nil || !detail.Credentials[0].TokenStored || bytes.Contains(raw, []byte(initial.Token)) {
|
||||
t.Fatal("metadata leaked or failed")
|
||||
}
|
||||
// 已启用密文保管的 CREATE、ROTATE、过渡期读取以及旧操作重放。
|
||||
create, err := store.PrepareApplication(ctx, admin, consoleauth.ApplicationInput{Kind: "CREATE", Application: "vault-new-" + string(testID(t)), Namespace: "default", Environment: "test", Queue: "work", Reason: "vault integration"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.ApplyApplication(ctx, admin, create); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := store.AccessCredentialToken(ctx, admin, create.NamespaceID, create.CredentialID, consoleauth.CredentialTokenInput{ExpectedRevision: 1}, false); err != nil || got.Token != create.Token {
|
||||
t.Fatal("new app not encrypted")
|
||||
}
|
||||
rotate, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1, Action: "ROTATE", GraceFor: time.Hour, Reason: "vault rotate"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
var readErr, rotateErr error
|
||||
var read consoleauth.CredentialToken
|
||||
go func() { defer wg.Done(); read, readErr = access(in, false) }()
|
||||
go func() { defer wg.Done(); _, rotateErr = ApplyCredentialOperation(ctx, db, rotate, ring) }()
|
||||
wg.Wait()
|
||||
if rotateErr != nil || (readErr != nil && fault.Code(readErr) != "STATE_CONFLICT") || (readErr == nil && read.Token != initial.Token) {
|
||||
t.Fatal("concurrent read/rotation inconsistent", rotateErr, readErr)
|
||||
}
|
||||
in.ExpectedRevision = 2
|
||||
if got, err := access(in, false); err != nil || got.Token != rotate.Token {
|
||||
t.Fatal("current token unavailable during grace")
|
||||
}
|
||||
in.Token = initial.Token
|
||||
if _, err := access(in, true); fault.Code(err) != "INVALID_ARGUMENT" {
|
||||
t.Fatal("old generation imported")
|
||||
}
|
||||
in.Token = ""
|
||||
status, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID)
|
||||
if err != nil || status.PreviousValidUntil == nil {
|
||||
t.Fatal("grace lost")
|
||||
}
|
||||
for _, action := range []string{"SET_EXPIRY", "FINALIZE"} {
|
||||
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: in.ExpectedRevision, Action: action, Reason: "preserve vault"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = ApplyCredentialOperation(ctx, db, op, ring); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.ExpectedRevision++
|
||||
if got, err := access(in, false); err != nil || got.Token != rotate.Token {
|
||||
t.Fatal("non-secret change invalidated encryption")
|
||||
}
|
||||
}
|
||||
if _, err := ApplyCredentialOperation(ctx, db, rotate, ring); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := access(in, false); err != nil || got.Token != rotate.Token {
|
||||
t.Fatal("replay changed ciphertext")
|
||||
}
|
||||
// 撤销会清除密文;历史 CLI 不配置密钥时轮换也不能遗留过期密文。
|
||||
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: in.ExpectedRevision, Action: "ROTATE", Reason: "legacy CLI"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = ApplyCredentialOperation(ctx, db, op); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.ExpectedRevision++
|
||||
if _, err := access(in, false); fault.Code(err) != "STATE_CONFLICT" {
|
||||
t.Fatal("stale ciphertext remained after CLI rotation")
|
||||
}
|
||||
in.Token = op.Token
|
||||
if _, err := access(in, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.Token = ""
|
||||
op, err = PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: in.ExpectedRevision, Action: "REVOKE", Reason: "revoke vault"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = ApplyCredentialOperation(ctx, db, op, ring); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.ExpectedRevision++
|
||||
if _, err := access(in, false); err == nil {
|
||||
t.Fatal("revoked credential revealed")
|
||||
}
|
||||
var stored bool
|
||||
if err := db.QueryRowContext(ctx, `SELECT secret_ciphertext IS NOT NULL FROM orbit_credentials WHERE namespace_id=? AND id=?`, initial.NamespaceID, initial.CredentialID).Scan(&stored); err != nil || stored {
|
||||
t.Fatal("revoked ciphertext remains")
|
||||
}
|
||||
// 审计只包含版本与操作身份,不包含 Token 或密文。
|
||||
rows, err := db.QueryContext(ctx, "SELECT CONCAT_WS(' ',action,`before`,`after`) FROM orbit_audit_events WHERE namespace_id=?", initial.NamespaceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
views := 0
|
||||
for rows.Next() {
|
||||
var text string
|
||||
if err := rows.Scan(&text); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(text, initial.Token) || strings.Contains(text, rotate.Token) {
|
||||
t.Fatal("secret leaked to audit")
|
||||
}
|
||||
if strings.Contains(text, "console.credential.token.view") {
|
||||
views++
|
||||
}
|
||||
}
|
||||
if rows.Err() != nil || views < 3 {
|
||||
t.Fatal("view audit missing", rows.Err())
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/credentialvault"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
@@ -67,7 +68,7 @@ type CredentialChangeInput struct {
|
||||
}
|
||||
|
||||
// CredentialOperation 是可安全重放的私有操作文件内容。
|
||||
// 令牌只存在于 ROTATE 文件中;服务端只保存其摘要和操作回执。
|
||||
// ROTATE 文件包含新令牌;服务端保存摘要、可选密文和不含秘密的操作回执。
|
||||
type CredentialOperation struct {
|
||||
// Version 操作文件格式版本,升级时不得静默改变语义。
|
||||
Version int `json:"version"`
|
||||
@@ -225,12 +226,16 @@ func ValidateCredentialOperation(op CredentialOperation) error {
|
||||
// ApplyCredentialOperation applies one frozen operation or returns its original
|
||||
// receipt. No callback or file I/O occurs inside the transaction. A commit error
|
||||
// is intentionally returned unchanged; replaying the same operation reconciles it.
|
||||
func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation) (CredentialChangeReceipt, error) {
|
||||
return applyCredentialOperation(ctx, db, op, nil)
|
||||
func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation, vault ...*credentialvault.Keyring) (CredentialChangeReceipt, error) {
|
||||
return applyCredentialOperation(ctx, db, op, nil, vault...)
|
||||
}
|
||||
|
||||
// applyCredentialOperation 允许控制台在同一事务先核验管理员,并使用真实人类审计主体。
|
||||
func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation, authorize func(*sql.Tx) (string, error)) (CredentialChangeReceipt, error) {
|
||||
func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation, authorize func(*sql.Tx) (string, error), vault ...*credentialvault.Keyring) (CredentialChangeReceipt, error) {
|
||||
var ring *credentialvault.Keyring
|
||||
if len(vault) > 0 {
|
||||
ring = vault[0]
|
||||
}
|
||||
var out CredentialChangeReceipt
|
||||
if err := ValidateCredentialOperation(op); err != nil {
|
||||
return out, err
|
||||
@@ -320,9 +325,14 @@ func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper
|
||||
out.PreviousValidUntil = timePtr(until)
|
||||
}
|
||||
}
|
||||
keyID, ciphertext, err := ring.Seal(op.NamespaceID, op.CredentialID, op.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 未配置保管密钥的旧 CLI 轮换也必须清除旧密文,不能回看过时代次。
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials
|
||||
SET secret_digest=?,expires_at=?,previous_secret_digest=?,previous_valid_until=?,revision=revision+1,updated_at=?
|
||||
WHERE namespace_id=? AND id=? AND revision=?`, parsed.Digest, op.ExpiresAt, previousDigest, out.PreviousValidUntil, now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
||||
SET secret_digest=?,expires_at=?,previous_secret_digest=?,previous_valid_until=?,secret_key_id=?,secret_ciphertext=?,revision=revision+1,updated_at=?
|
||||
WHERE namespace_id=? AND id=? AND revision=?`, parsed.Digest, op.ExpiresAt, previousDigest, out.PreviousValidUntil, keyID, ciphertext, now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
||||
return err
|
||||
}
|
||||
out.ExpiresAt = op.ExpiresAt
|
||||
@@ -341,7 +351,7 @@ func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper
|
||||
}
|
||||
out.PreviousValidUntil = nil
|
||||
case credentialRevokeAction:
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET revoked_at=?,previous_secret_digest=NULL,previous_valid_until=NULL,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, now, now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET revoked_at=?,previous_secret_digest=NULL,previous_valid_until=NULL,secret_key_id=NULL,secret_ciphertext=NULL,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, now, now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
||||
return err
|
||||
}
|
||||
out.RevokedAt = timePtr(now)
|
||||
|
||||
@@ -26,7 +26,7 @@ var migrationFiles embed.FS
|
||||
//go:embed baseline.sql
|
||||
var freshInstallBaseline string
|
||||
|
||||
const freshInstallBaselineVersion uint64 = 32
|
||||
const freshInstallBaselineVersion uint64 = 33
|
||||
|
||||
type Migration struct {
|
||||
Version uint64
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 管理员可回看当前 Token;旧记录保留为空,导入只补密文,不轮换秘密。
|
||||
ALTER TABLE orbit_credentials
|
||||
ADD COLUMN secret_key_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL COMMENT '当前 Token 密文使用的主密钥标识;空表示尚未保管,主密钥不存数据库',
|
||||
ADD COLUMN secret_ciphertext VARBINARY(1024) NULL COMMENT '当前 Token 的 AES-256-GCM nonce 与认证密文;空表示旧凭据仅存摘要',
|
||||
ADD CONSTRAINT ck_credential_ciphertext CHECK ((secret_key_id IS NULL AND secret_ciphertext IS NULL) OR (secret_key_id IS NOT NULL AND secret_ciphertext IS NOT NULL AND LENGTH(secret_key_id)>0 AND LENGTH(secret_ciphertext)>=28)),
|
||||
COMMENT='命名空间凭据、认证摘要、授权范围及可选的当前 Token 密文';
|
||||
@@ -3,6 +3,7 @@
|
||||
const ui = { after: '', next: '', selected: '', detail: null, loading: false, busy: false, pending: null, rotating: null, result: null, saved: true, mode: 'CREATE', helpCredential: '' };
|
||||
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const time = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '长期有效';
|
||||
let tokenRequest = 0, tokenTarget = null;
|
||||
function message(error) { return error.status === 403 ? '此账号未启用应用接入管理。请在服务端配置 ORBIT_CONSOLE_APPLICATION_ADMIN 为管理员登录名。' : error.message; }
|
||||
async function api(path = '', body) {
|
||||
const response = await fetch(`/console/applications${path}`, { method: body ? 'POST' : 'GET', credentials: 'same-origin', cache: 'no-store', headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json', 'X-Orbit-Console': '1' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) });
|
||||
@@ -11,11 +12,11 @@
|
||||
if (!response.ok) { const error = new Error(value.error?.message || `请求失败 (${response.status})`); error.status = response.status; throw error; }
|
||||
return value.data;
|
||||
}
|
||||
function config(token = '<填入已保存的 Token>', queue = $('access-queue').value, namespace = ui.selected) {
|
||||
function config(token = '••••••••', queue = $('access-queue').value, namespace = ui.selected) {
|
||||
const url = $('access-url').value.trim() || location.origin;
|
||||
let parsed; try { parsed = new URL(url); } catch { throw new Error('请输入完整的 HTTP / HTTPS 服务地址'); }
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash || /[\s'"`$\\]/.test(url)) throw new Error('服务地址必须是不含账号、查询参数或特殊字符的 HTTP / HTTPS 地址');
|
||||
return `ORBIT_URL=${url.replace(/\/$/, '')}\nORBIT_TOKEN=${token}\nORBIT_NAMESPACE_ID=${namespace}\nORBIT_QUEUE_ID=${queue}\n`;
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash || /[\s'"`$\\;&|<>(){}]/.test(url)) throw new Error('服务地址必须是不含账号、查询参数或特殊字符的 HTTP / HTTPS 地址');
|
||||
return `ORBIT_URL=${url.replace(/\/$/, '')}\nORBIT_APPLICATION_ID=${ui.detail?.entry.application_id || ''}\nORBIT_NAMESPACE_ID=${namespace}\nORBIT_QUEUE_ID=${queue}\nORBIT_TOKEN=${token}\n`;
|
||||
}
|
||||
async function load() {
|
||||
if (ui.loading || ui.busy) return;
|
||||
@@ -24,7 +25,7 @@
|
||||
const page = await api(ui.after ? `?after=${encodeURIComponent(ui.after)}` : ''); ui.next = page.next || ''; $('access-next').disabled = !ui.next;
|
||||
$('access-list').innerHTML = page.items.length ? page.items.map(entry => `<button class="access-item ${entry.namespace_id === ui.selected ? 'selected' : ''}" data-access-id="${escape(entry.namespace_id)}"><span class="access-avatar">${escape(entry.application.slice(0, 1).toUpperCase())}</span><span><strong>${escape(entry.application)}</strong><small>${escape(entry.environment)} / ${escape(entry.namespace)} · ${entry.enabled ? '已启用' : '已停用'}</small></span></button>`).join('') : '<p class="panel-description">还没有接入应用,点击右上角新建。</p>';
|
||||
if (!ui.selected && page.items.length) await select(page.items[0].namespace_id);
|
||||
else if (ui.selected && !$('access-dialog').open) await select(ui.selected);
|
||||
else if (ui.selected && !$('access-dialog').open && !$('access-token-dialog').open) await select(ui.selected);
|
||||
} catch (error) { $('access-error').textContent = message(error); }
|
||||
finally { ui.loading = false; }
|
||||
}
|
||||
@@ -46,12 +47,13 @@
|
||||
const blocked = !!c.revoked_at || transitioning;
|
||||
const helpID = `access-rotation-help-${c.id}`;
|
||||
const reason = c.revoked_at ? '凭据已撤销,不能再次换证。' : '旧 Token 仍在过渡期,暂不能再次换证,以免中断尚未切换的服务。';
|
||||
const rotationHelp = transitioning ? `<aside class="access-rotation-notice" id="${escape(helpID)}"><strong>旧 Token 仍在过渡期</strong><p>${escape(reason)}</p><p>过渡截止:<time datetime="${escape(c.previous_valid_until)}">${escape(time(c.previous_valid_until))}</time>。到期后,换证按钮随页面刷新恢复可用。</p><p>请用已保存的新 Token 更新服务、Worker 和结果消费者配置。确认全部切换、在途任务收尾后,也可提前点击「结束过渡,停用旧 Token」。</p></aside>` : c.revoked_at ? `<p id="${escape(helpID)}">${escape(reason)}</p>` : c.previous_valid_until ? `<p>旧 Token 已失效于:${escape(time(c.previous_valid_until))},可再次换证。</p>` : '';
|
||||
const rotationHelp = transitioning ? `<aside class="access-rotation-notice" id="${escape(helpID)}"><strong>旧 Token 仍在过渡期</strong><p>${escape(reason)}</p><p>过渡截止:<time datetime="${escape(c.previous_valid_until)}">${escape(time(c.previous_valid_until))}</time>。到期后,换证按钮随页面刷新恢复可用。</p><p>请在上方复制当前接入配置,更新服务、Worker 和结果消费者。确认全部切换、在途任务收尾后,也可提前点击「结束过渡,停用旧 Token」。</p></aside>` : c.revoked_at ? `<p id="${escape(helpID)}">${escape(reason)}</p>` : c.previous_valid_until ? `<p>旧 Token 已失效于:${escape(time(c.previous_valid_until))},可再次换证。</p>` : '';
|
||||
const groups = [['tasks:submit', '任务'], ['workers:consume', 'Worker 执行'], ['events:consume', '结果订阅'], ['schedules:manage', '定时调度'], ['workflows:start', '流程编排'], ['tasks:review', '人工恢复'], ['application:resources:manage', '应用额度']].filter(([action]) => c.permissions.actions.includes(action)).map(([,name]) => name);
|
||||
return `<section class="access-credential">
|
||||
<div class="access-credential-header"><b>${escape(c.name)} · ${status}</b><button class="text-button ${blocked ? 'access-rotation-blocked' : ''}" ${blocked ? `data-access-help="${escape(c.id)}" title="${escape(reason)}" aria-describedby="${escape(helpID)}"` : `data-access-rotate="${escape(c.id)}"`} aria-haspopup="dialog">${c.revoked_at ? '已撤销 · 查看原因 ⓘ' : transitioning ? '暂不可换证 · 查看原因 ⓘ' : '平滑换证 ↗'}</button></div>
|
||||
<p>凭据 ID:${escape(c.id)}<br>有效期:${escape(time(c.expires_at))} · 凭据版本 ${escape(c.revision)}</p>
|
||||
<div class="access-token-summary"><div><strong>当前 Token</strong><span>原文仅在生成时显示,无法回看</span></div><button type="button" class="text-button" data-access-help="${escape(c.id)}" aria-haspopup="dialog">Token 在哪里查看?</button></div>
|
||||
<div class="access-token-summary"><div><strong>接入配置 · 当前 Token</strong><span>${c.revoked_at ? '凭据已撤销' : c.token_stored ? '•••••••••••••••• · 已加密保管' : '历史凭据 · 导入当前 Token 后可随时查看'}</span></div></div>
|
||||
<div class="access-form-actions"><button type="button" class="primary-button compact" data-access-token="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>${c.token_stored ? '查看接入信息' : '导入当前 Token'}</button><button type="button" class="secondary-button compact" data-access-copy-config="${escape(c.id)}" ${c.revoked_at || !c.token_stored ? 'disabled' : ''}>复制接入配置 ↗</button></div>
|
||||
${rotationHelp}
|
||||
<div class="access-form-actions"><button class="text-button" data-access-expiry="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>调整有效期</button>${c.previous_valid_until && !c.revoked_at ? `<button class="text-button" data-access-finalize="${escape(c.id)}">结束过渡,停用旧 Token</button>` : ''}</div>
|
||||
<div class="access-permissions">${groups.map(a => `<span>${escape(a)}</span>`).join('')}</div><details class="access-permission-details"><summary>查看具体权限</summary><p>${c.permissions.actions.map(escape).join(' · ')}<br>授权队列:${escape(c.permissions.queues.join(', '))}</p></details>
|
||||
@@ -62,6 +64,43 @@
|
||||
} catch (error) { $('access-error').textContent = message(error); }
|
||||
}
|
||||
function renderConfig() { try { $('access-config').textContent = config(); } catch (error) { $('access-config').textContent = error.message; } }
|
||||
function closeToken() {
|
||||
tokenRequest++; tokenTarget = null;
|
||||
$('access-token-import-submit').disabled = false;
|
||||
$('access-token-config').value = ''; $('access-token-input').value = '';
|
||||
$('access-token-dialog').close();
|
||||
}
|
||||
async function showToken(credential, copyImmediately = false) {
|
||||
closeToken();
|
||||
tokenTarget = { namespace: ui.selected, credential, queue: $('access-queue').value };
|
||||
// 只导出该凭据已授权的队列,不能拼出无法使用的配置。
|
||||
if (!credential.permissions.queues.includes('*') && !credential.permissions.queues.includes(tokenTarget.queue)) tokenTarget.queue = ui.detail.queues.find(q => credential.permissions.queues.includes(q.id))?.id || '';
|
||||
$('access-token-scope').textContent = `${ui.detail.entry.application} · ${ui.detail.entry.environment} / ${ui.detail.entry.namespace} · 凭据版本 ${credential.revision}`;
|
||||
$('access-token-error').textContent = '';
|
||||
$('access-token-import').hidden = credential.token_stored;
|
||||
$('access-token-output').hidden = true;
|
||||
$('access-token-dialog').showModal();
|
||||
if (!ui.detail.token_vault_enabled) { $('access-token-error').textContent = '服务端尚未配置 Token 保管密钥,请联系部署管理员。'; return; }
|
||||
if (credential.token_stored) await readToken(false, copyImmediately);
|
||||
else $('access-token-input').focus();
|
||||
}
|
||||
async function readToken(importCurrent, copyImmediately = false) {
|
||||
const target = tokenTarget; if (!target) return;
|
||||
const request = ++tokenRequest;
|
||||
$('access-token-error').textContent = '正在读取接入配置…'; $('access-token-import-submit').disabled = true;
|
||||
try {
|
||||
if (!target.queue) throw new Error('没有可用的授权队列,请先配置队列权限。');
|
||||
const value = await api(`/${encodeURIComponent(target.namespace)}/credentials/${encodeURIComponent(target.credential.id)}/token/${importCurrent ? 'import' : 'reveal'}`, { expected_revision: String(target.credential.revision), ...(importCurrent ? { token: $('access-token-input').value.trim() } : {}) });
|
||||
if (request !== tokenRequest || ui.selected !== target.namespace || !$('access-token-dialog').open) return;
|
||||
if (value.namespace_id !== target.namespace || value.credential_id !== target.credential.id || String(value.revision) !== String(target.credential.revision)) throw new Error('凭据已更新,请刷新后重试。');
|
||||
$('access-token-config').value = config(value.token, target.queue, target.namespace);
|
||||
$('access-token-import').hidden = true; $('access-token-input').value = '';
|
||||
$('access-token-output').hidden = false; $('access-token-error').textContent = '';
|
||||
if (importCurrent) target.credential.token_stored = true;
|
||||
if (copyImmediately) await copy($('access-token-config').value, $('access-token-copy'));
|
||||
} catch (error) { if (request === tokenRequest) $('access-token-error').textContent = message(error); }
|
||||
finally { if (request === tokenRequest) $('access-token-import-submit').disabled = false; }
|
||||
}
|
||||
function renderCredentialHelp() {
|
||||
const credential = ui.detail?.credentials.find(c => c.id === ui.helpCredential);
|
||||
if (!credential) { $('access-help-dialog').close(); return; }
|
||||
@@ -96,7 +135,7 @@
|
||||
}
|
||||
function close() {
|
||||
if (ui.busy) return;
|
||||
if ((ui.pending || !ui.saved) && !window.confirm(ui.pending ? '操作结果尚未确认,请先保存待核对操作。仍要关闭吗?' : '请确认已保存完整 SDK 配置。关闭后无法再次查看 Token;如果还在换证过渡期,也不能立即再次换证。仍要关闭吗?')) return;
|
||||
if (ui.pending && !window.confirm('操作结果尚未确认,请先保存待核对操作。仍要关闭吗?')) return;
|
||||
$('access-dialog').close(); ui.pending = null; ui.result = null; ui.saved = true; $('access-result-config').value = ''; load();
|
||||
}
|
||||
function download(name, content, type = 'text/plain') {
|
||||
@@ -104,7 +143,7 @@
|
||||
}
|
||||
async function copy(value, button) {
|
||||
try { await navigator.clipboard.writeText(value); const old = button.textContent; button.textContent = '已复制 ✓'; setTimeout(() => { button.textContent = old; }, 2000); return true; }
|
||||
catch { ($('access-dialog').open ? $('access-clipboard-status') : $('access-error')).textContent = '浏览器未允许复制,请选中配置后手动复制,或下载 .env 文件。'; return false; }
|
||||
catch { ($('access-token-dialog').open ? $('access-token-error') : $('access-dialog').open ? $('access-clipboard-status') : $('access-error')).textContent = '浏览器未允许复制,请选中配置后手动复制,或下载 .env 文件。'; return false; }
|
||||
}
|
||||
async function submit(event) {
|
||||
event.preventDefault(); if (ui.busy) return; ui.busy = true; $('access-form-error').textContent = '';
|
||||
@@ -121,7 +160,7 @@
|
||||
form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; });
|
||||
const op = ui.pending;
|
||||
await api('/apply', op);
|
||||
ui.saved = !op.token;
|
||||
ui.saved = true;
|
||||
let queue = op.queue_id || $('access-queue').value;
|
||||
// 先确认当前凭据版本,旧操作回执不能冒充仍有效的最新 Token。
|
||||
await select(op.namespace_id);
|
||||
@@ -146,21 +185,29 @@
|
||||
}
|
||||
}
|
||||
$('access-url').value = location.origin;
|
||||
$('access-list').addEventListener('click', e => { const el = e.target.closest('[data-access-id]'); if (el) select(el.dataset.accessId); });
|
||||
$('access-list').addEventListener('click', e => { const el = e.target.closest('[data-access-id]'); if (el) { closeToken(); select(el.dataset.accessId); } });
|
||||
$('access-credentials').addEventListener('click', e => {
|
||||
const el = e.target.closest('[data-access-help],[data-access-rotate],[data-access-expiry],[data-access-finalize]'); if (!el || el.disabled) return;
|
||||
const id = el.dataset.accessHelp || el.dataset.accessRotate || el.dataset.accessExpiry || el.dataset.accessFinalize;
|
||||
const el = e.target.closest('[data-access-help],[data-access-rotate],[data-access-expiry],[data-access-finalize],[data-access-token],[data-access-copy-config]'); if (!el || el.disabled) return;
|
||||
const id = el.dataset.accessHelp || el.dataset.accessRotate || el.dataset.accessExpiry || el.dataset.accessFinalize || el.dataset.accessToken || el.dataset.accessCopyConfig;
|
||||
const credential = ui.detail?.credentials.find(c => c.id === id);
|
||||
if (credential && (el.dataset.accessToken || el.dataset.accessCopyConfig)) { showToken(credential, !!el.dataset.accessCopyConfig); return; }
|
||||
if (credential && el.dataset.accessHelp) { showCredentialHelp(credential); return; }
|
||||
if (credential && !credential.revoked_at) open(credential, el.dataset.accessExpiry ? 'SET_EXPIRY' : el.dataset.accessFinalize ? 'FINALIZE' : 'ROTATE');
|
||||
});
|
||||
$('access-help-close').addEventListener('click', () => $('access-help-dialog').close());
|
||||
$('access-help-done').addEventListener('click', () => $('access-help-dialog').close());
|
||||
$('access-token-close').addEventListener('click', () => { closeToken(); load(); });
|
||||
$('access-token-dialog').addEventListener('cancel', e => { e.preventDefault(); closeToken(); load(); });
|
||||
$('access-token-import').addEventListener('submit', e => { e.preventDefault(); readToken(true); });
|
||||
$('access-token-copy').addEventListener('click', () => copy($('access-token-config').value, $('access-token-copy')));
|
||||
$('access-token-download').addEventListener('click', () => download('orbit.env', $('access-token-config').value));
|
||||
window.addEventListener('hashchange', closeToken);
|
||||
window.addEventListener('pagehide', closeToken);
|
||||
$('access-create').addEventListener('click', () => open()); $('access-close').addEventListener('click', close);
|
||||
$('access-dialog').addEventListener('cancel', e => { e.preventDefault(); close(); });
|
||||
$('access-refresh').addEventListener('click', load); $('access-next').addEventListener('click', () => { ui.after = ui.next; load(); }); $('access-first').addEventListener('click', () => { ui.after = ''; load(); });
|
||||
$('access-url').addEventListener('input', renderConfig); $('access-queue').addEventListener('change', renderConfig);
|
||||
$('access-copy-template').addEventListener('click', async () => { try { await copy(config(), $('access-copy-template')); } catch (error) { $('access-error').textContent = error.message; } });
|
||||
$('access-copy-template').addEventListener('click', () => { const credential = ui.detail?.credentials.find(c => !c.revoked_at); if (credential) showToken(credential); });
|
||||
$('access-form').addEventListener('submit', submit);
|
||||
$('access-copy').addEventListener('click', async () => { if (await copy($('access-result-config').value, $('access-copy'))) ui.saved = true; });
|
||||
$('access-download').addEventListener('click', () => { download('orbit.env', $('access-result-config').value); ui.saved = true; });
|
||||
|
||||
@@ -224,8 +224,8 @@
|
||||
<p class="panel-description">本机默认使用当前页面地址;容器内可填写 http://orbit-test:8080,部署后可换成服务域名。</p>
|
||||
<div class="access-credentials" id="access-credentials"></div>
|
||||
<p class="panel-description" id="access-truncated" hidden>队列或凭据超过 100 项,此页只展示前 100 项。</p>
|
||||
<div class="access-config-heading"><h3>SDK 配置模板</h3><button class="text-button" id="access-copy-template">复制配置模板 ↗</button></div>
|
||||
<p class="panel-description">此模板不含完整 Token。请将 ORBIT_TOKEN 替换为生成凭据时保存的值;上面的凭据 ID 不能作为 Token 使用。</p>
|
||||
<div class="access-config-heading"><h3>接入配置预览</h3><button class="text-button" id="access-copy-template">查看完整接入配置 ↗</button></div>
|
||||
<p class="panel-description">预览默认隐藏 Token。点击「查看完整接入配置」,即可复制包含当前 Token 的配置,或下载 orbit.env。</p>
|
||||
<pre class="access-code" id="access-config"></pre>
|
||||
<div class="access-help">Token 确定应用和权限,队列决定任务去向。业务服务注册相同名称与版本的 handler 后,即可执行任务。</div>
|
||||
</div>
|
||||
@@ -397,7 +397,7 @@
|
||||
<dialog id="access-dialog" class="access-dialog">
|
||||
<div class="access-dialog-top"><span class="panel-kicker">ORBIT / CONNECT</span><button type="button" class="icon-button" id="access-close" aria-label="关闭应用接入">×</button></div>
|
||||
<h2 id="access-form-title">新建应用</h2><p class="panel-description" id="access-form-note">为应用创建独立的环境、队列与调用凭据。</p>
|
||||
<p class="access-token-once-note" id="access-token-once-note">完整 Token 只在生成成功后显示一次,请当场复制或下载保存。关闭后无法回看;换证过渡期内也不能立即再次换证。</p>
|
||||
<p class="access-token-once-note" id="access-token-once-note">Token 会加密保存。管理员可随时查看接入信息、复制完整配置或下载 orbit.env。</p>
|
||||
<form id="access-form">
|
||||
<div id="access-create-fields">
|
||||
<label>应用名称<input id="access-name" maxlength="128" placeholder="例如 order-service" autocomplete="off" required pattern="[A-Za-z0-9][A-Za-z0-9_.:\-]*"></label>
|
||||
@@ -411,21 +411,35 @@
|
||||
<div class="access-form-actions"><button type="submit" class="primary-button compact" id="access-submit">创建并生成凭据 →</button><button type="button" class="text-button" id="access-backup" hidden>保存待核对操作</button></div>
|
||||
</form>
|
||||
<p id="access-clipboard-status" class="panel-description" role="status"></p>
|
||||
<section id="access-result" hidden><div class="access-success">✓ <span id="access-result-title">应用已创建</span></div><p class="access-token-once-note" id="access-result-note">请现在复制或下载下面的完整 SDK 配置。Token 只在本次操作中显示,关闭或刷新后无法回看;旧 Token 仍在过渡期时,不能立即再次换证。</p><label id="access-result-config-field">完整 SDK 配置<textarea id="access-result-config" readonly rows="7" spellcheck="false"></textarea></label><p class="panel-description" id="access-result-expiry"></p><div class="access-form-actions" id="access-result-actions"><button type="button" class="primary-button compact" id="access-copy">复制配置 ↗</button><button type="button" class="secondary-button compact" id="access-download">下载 .env ↓</button></div></section>
|
||||
<section id="access-result" hidden><div class="access-success">✓ <span id="access-result-title">应用已创建</span></div><p class="access-token-once-note" id="access-result-note">复制下面的完整接入配置即可用于业务服务。关闭后仍可从凭据卡片的「查看接入信息」重新获取当前 Token。</p><label id="access-result-config-field">完整 SDK 配置<textarea id="access-result-config" readonly rows="7" spellcheck="false"></textarea></label><p class="panel-description" id="access-result-expiry"></p><div class="access-form-actions" id="access-result-actions"><button type="button" class="primary-button compact" id="access-copy">复制配置 ↗</button><button type="button" class="secondary-button compact" id="access-download">下载 .env ↓</button></div></section>
|
||||
</dialog>
|
||||
<dialog id="access-help-dialog" class="access-dialog access-credential-help" aria-labelledby="access-help-title">
|
||||
<div class="access-dialog-top"><span class="panel-kicker">ORBIT / CREDENTIAL</span><button type="button" class="icon-button" id="access-help-close" aria-label="关闭凭据说明">×</button></div>
|
||||
<h2 id="access-help-title">凭据与换证说明</h2>
|
||||
<p class="panel-description" id="access-help-scope"></p>
|
||||
<div class="access-rotation-notice"><strong>当前换证状态</strong><p id="access-help-state"></p></div>
|
||||
<h3>当前 Token 在哪里查看?</h3>
|
||||
<p>请打开生成凭据时下载的 <code>orbit.env</code>、已保存的 SDK 配置,或业务服务的配置,找到 <code>ORBIT_TOKEN</code>。凭据 ID 只是标识,不能代替 Token。</p>
|
||||
<p>控制台只能查看凭据状态、有效期、版本和权限。完整 Token 仅在创建或换证成功时显示,关闭后无法从平台还原。</p>
|
||||
<div id="access-help-transition" hidden><p>过渡期内,业务配置可能仍是旧 Token;新 Token 请以本次换证时保存的配置为准。</p><p>确认所有服务已切换新 Token、在途任务已收尾后,才可以提前结束过渡。该操作会立即停用旧 Token。</p></div>
|
||||
<details class="access-token-recovery"><summary>如果新 Token 没有保存怎么办?</summary><p>先检查下载文件、已保存的配置或已更新的服务配置。若都没有保存,平台无法找回原文,需要重新生成并更新服务配置。</p><p>仍在过渡期时,不要为了解锁按钮直接结束过渡,这会立即停用旧 Token。确需重置,应先安排维护窗口,暂停接收新任务并收尾在途执行,再由管理员结束过渡、重新换证,保存新配置并更新服务后恢复运行。</p></details>
|
||||
<h3>查看和复制当前接入配置</h3>
|
||||
<p>在凭据卡片点击「查看接入信息」或「复制接入配置」。配置包含服务地址、应用 ID、命名空间、队列和当前 Token;换证过渡期也可以获取。</p>
|
||||
<div id="access-help-transition" hidden><p>当前新 Token 可以正常查看和复制。确认所有服务已切换、在途任务已收尾后,再结束旧 Token 过渡。</p></div>
|
||||
<div class="access-form-actions"><button type="button" class="primary-button compact" id="access-help-done">我知道了</button></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="access-token-dialog" class="access-dialog" aria-labelledby="access-token-title">
|
||||
<div class="access-dialog-top"><span class="panel-kicker">ORBIT / INTEGRATION</span><button type="button" class="icon-button" id="access-token-close" aria-label="关闭接入信息">×</button></div>
|
||||
<h2 id="access-token-title">接入信息</h2><p class="panel-description" id="access-token-scope"></p>
|
||||
<form id="access-token-import" hidden>
|
||||
<p class="panel-description">此凭据创建于 Token 保管功能启用之前。导入已保存的当前 Token 后,管理员即可随时查看和复制完整配置。导入不会换证,也不会改变有效期或过渡时间。</p>
|
||||
<label>当前 Token<input id="access-token-input" type="password" autocomplete="off" spellcheck="false" maxlength="512" required placeholder="粘贴当前 ORBIT_TOKEN"></label>
|
||||
<button type="submit" class="primary-button compact" id="access-token-import-submit">导入并查看接入配置 →</button>
|
||||
</form>
|
||||
<section id="access-token-output" hidden>
|
||||
<label>完整接入配置<textarea id="access-token-config" readonly rows="8" spellcheck="false" autocomplete="off"></textarea></label>
|
||||
<p class="panel-description">按所选服务地址和授权队列生成,包含当前 Token。可以直接粘贴到业务服务的环境配置中。</p>
|
||||
<div class="access-form-actions"><button type="button" class="primary-button compact" id="access-token-copy">复制接入配置 ↗</button><button type="button" class="secondary-button compact" id="access-token-download">下载 orbit.env ↓</button></div>
|
||||
</section>
|
||||
<p class="action-error" id="access-token-error" role="status"></p>
|
||||
</dialog>
|
||||
|
||||
<script src="/console/applications.js"></script>
|
||||
<script src="/console/app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -102,4 +102,25 @@ func RegisterConsoleApplications(server *http.Server, consoleAuth consoleauth.St
|
||||
}
|
||||
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||
})
|
||||
if secrets, ok := store.(consoleauth.CredentialTokens); ok {
|
||||
for _, action := range []string{"reveal", "import"} {
|
||||
mux.HandleFunc("POST /console/applications/{namespace}/credentials/{credential}/token/"+action, func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := check(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in consoleauth.CredentialTokenInput
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
writeError(w, "", err)
|
||||
return
|
||||
}
|
||||
out, err := secrets.AccessCredentialToken(r.Context(), token, core.ID(r.PathValue("namespace")), core.ID(r.PathValue("credential")), in, action == "import")
|
||||
if err != nil {
|
||||
writeError(w, "", err)
|
||||
return
|
||||
}
|
||||
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user