Merge pull request 'feat(identity): 合并标准统一认证接入与安全事件撤销' (#7) from codex/merge-identity-main into main
Some checks failed
ci / verify (push) Failing after 34s

Reviewed-on: #7
This commit is contained in:
chengcheng 2026-07-17 19:36:19 +08:00
commit 82a494051d
103 changed files with 20298 additions and 623 deletions

View File

@ -23,37 +23,19 @@ CONFIG_JWT_SECRET=this is a very secret secret
# - hybrid: both sources are accepted and separated by gateway_users.source.
IDENTITY_MODE=hybrid
# Auth Center stable OIDC verification. Keep legacy HS256 during the staged rollout.
OIDC_ENABLED=false
OIDC_ISSUER=https://auth.51easyai.com/issuer/shared
OIDC_AUDIENCE=https://api.51easyai.com/gateway
OIDC_TENANT_ID=
OIDC_ROLE_PREFIX=gateway.
OIDC_REQUIRED_SCOPES=gateway.access
OIDC_JWKS_CACHE_TTL_SECONDS=300
OIDC_ACCEPT_LEGACY_HS256=true
OIDC_INTROSPECTION_ENABLED=false
OIDC_INTROSPECTION_CLIENT_ID=
OIDC_INTROSPECTION_CLIENT_SECRET=
# Controlled JIT is opt-in. When enabled, bind the validated Auth Center tid to
# one existing active Gateway tenant; tokens never create Gateway tenants.
OIDC_JIT_PROVISIONING_ENABLED=false
OIDC_GATEWAY_TENANT_KEY=
OIDC_BROWSER_SESSION_ENABLED=true
# Staging/production must use true. Local HTTP development may use false.
OIDC_SESSION_COOKIE_SECURE=false
# Public Client ID generated by Auth Center Control Plane. No Client Secret is used.
OIDC_CLIENT_ID=
OIDC_REDIRECT_URI=http://localhost:8088/api/v1/auth/oidc/callback
OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5178/
# Generate a dedicated value with: openssl rand -base64 32
# Keep the real value only in a Git-ignored local file or Secret Manager.
OIDC_SESSION_ENCRYPTION_KEY=
OIDC_SESSION_IDLE_TTL_SECONDS=1800
OIDC_SESSION_ABSOLUTE_TTL_SECONDS=28800
OIDC_SESSION_REFRESH_BEFORE_SECONDS=60
VITE_OIDC_BROWSER_SESSION_ENABLED=true
VITE_OIDC_ENABLED=false
# Unified identity business settings are managed in System Settings > Unified
# Identity. Deployment only supplies the SecretStore and infrastructure timing.
AI_GATEWAY_PUBLIC_BASE_URL=http://localhost:8088
IDENTITY_SECRET_STORE=file
IDENTITY_SECRET_DIR=.local-secrets/identity
# Kubernetes uses one pre-provisioned empty Secret and narrowly scoped RBAC.
# IDENTITY_SECRET_STORE=kubernetes
# IDENTITY_KUBERNETES_NAMESPACE=easyai
# IDENTITY_KUBERNETES_SECRET_NAME=easyai-gateway-identity
# IDENTITY_KUBERNETES_API_SERVER=https://kubernetes.default.svc
IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS=60
IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS=180
IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS=60
AI_GATEWAY_WEB_BASE_URL=http://localhost:5178
AI_GATEWAY_WEB_BASE_PATH=/
AI_GATEWAY_GO_BUILD_IMAGE=golang:1.26.3-alpine

1
.gitignore vendored
View File

@ -5,6 +5,7 @@ node_modules/
.DS_Store
.env
.env.local
.local-secrets/
.gateway-local-password
*.log

View File

@ -71,13 +71,9 @@ COPY packages packages
COPY apps/web apps/web
ARG VITE_GATEWAY_API_BASE_URL=/gateway-api
ARG VITE_OIDC_ENABLED=false
ARG VITE_OIDC_BROWSER_SESSION_ENABLED=true
ARG VITE_BASE_PATH=/
ENV VITE_GATEWAY_API_BASE_URL=$VITE_GATEWAY_API_BASE_URL
ENV VITE_OIDC_ENABLED=$VITE_OIDC_ENABLED \
VITE_OIDC_BROWSER_SESSION_ENABLED=$VITE_OIDC_BROWSER_SESSION_ENABLED \
VITE_BASE_PATH=$VITE_BASE_PATH
ENV VITE_BASE_PATH=$VITE_BASE_PATH
RUN pnpm --filter @easyai-ai-gateway/web build
FROM ${WEB_RUNTIME_IMAGE} AS web

View File

@ -36,25 +36,21 @@ pnpm dev
- PostgreSQL: 目标版本 18默认使用宿主机 `localhost:5432` 上的 `easyai-pgvector` 实例,并使用独立库 `easyai_ai_gateway`
- 身份模式: 默认 `IDENTITY_MODE=hybrid`,可同时测试 Gateway 本地账号注册登录、可选邀请码和 `server-main` JWT / API Key 对接。
### Auth Center OIDC 受控 JIT
### Auth Center 统一认证
OIDC 用户通过签名、Issuer、Audience、`tid`、Scope 和应用角色校验后Gateway 可在首个受保护请求前创建本地业务投影。该投影只用于 Gateway 的用户组、钱包、API Key、任务归属和审计不修改 Auth Center Claims也不迁移或合并历史账号。
统一认证不再通过 `OIDC_*``VITE_OIDC_*` 业务环境变量配置。管理员先在 Auth Center 的通用“应用接入”向导选择 OIDC 登录、API 验证、机器调用、Token Introspection 和 SSF 会话撤销等标准能力,再到 Gateway 的“系统设置 → 统一认证”填写 Auth Center 地址、一次性接入码、API/Web 公网地址和本地租户映射。Gateway 自动领取标准 Manifest 与一次性机器凭据,验证通过后热切换,无需重启。
部署环境只提供 SecretStore 等启动级配置:
```dotenv
OIDC_ENABLED=true
OIDC_JIT_PROVISIONING_ENABLED=true
OIDC_GATEWAY_TENANT_KEY=default
OIDC_BROWSER_SESSION_ENABLED=true
OIDC_SESSION_COOKIE_SECURE=false # 本地 HTTP生产必须为 true
OIDC_CLIENT_ID=<公共 Client ID>
OIDC_REDIRECT_URI=http://localhost:8088/api/v1/auth/oidc/callback
OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5178/
OIDC_SESSION_ENCRYPTION_KEY=<base64 编码的独立 32 字节随机密钥>
IDENTITY_SECRET_STORE=file
IDENTITY_SECRET_DIR=.local-secrets/identity
IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS=60
IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS=180
IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS=60
```
`OIDC_GATEWAY_TENANT_KEY` 必须指向已有且启用的 Gateway 租户及其默认用户组JIT 不会从 Token 自动创建租户。开关默认关闭,关闭后不再创建新用户,但仍可解析此前创建的 `source=oidc` 映射。
Web Console 使用公共 Client + PKCE + Gateway BFF SessionGateway 服务端换码并加密保存 Access/Refresh/ID Token浏览器 JavaScript 只持有不可读的 HttpOnly 随机 Session Cookie。Access Token 仍为 5 分钟并由业务请求按需刷新Session 闲置 30 分钟、最长 8 小时。Gateway 不使用 Client Secret也不二次签发 JWT。完整行为、错误码和回滚方式见 [OIDC JIT 接入说明](docs/oidc-jit-provisioning.md)。
OIDC 用户通过签名、Issuer、Audience、`tid`、Scope 和应用角色校验后Gateway 可按当前 Active Revision 的策略创建本地业务投影。Web Console 使用公共 Client + PKCE + Gateway BFF Session浏览器 JavaScript 只持有 HttpOnly 随机 Session Cookie。可选 SSF/CAEP 能力自动复用同一机器凭据,推送不健康时降级到 RFC 7662内省也不可用时 OIDC Fail Closed。完整行为见 [统一认证运行时配置](docs/standard-identity-runtime-configuration.md)、[OIDC JIT 接入说明](docs/oidc-jit-provisioning.md)和 [SSF 会话撤销运行手册](docs/security/ssf-session-revocation.md)。
`pnpm dev` 会先创建数据库并执行 migration然后并行启动

View File

@ -0,0 +1,102 @@
package main
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
)
const identityPairingStartReservationMigration = "../../migrations/0068_identity_pairing_start_reservation.sql"
func TestIdentityPairingStartReservationMigrationDefinesCurrentLifecycle(t *testing.T) {
payload, err := os.ReadFile(identityPairingStartReservationMigration)
if err != nil {
t.Fatal(err)
}
content := strings.ToLower(string(payload))
for _, required := range []string{
"state text not null default 'starting'",
"revision_id uuid unique references gateway_identity_configuration_revisions",
"updated_at timestamptz not null default now()",
"gateway_identity_pairing_start_state_check",
"idx_gateway_identity_pairing_start_expiry",
"on conflict do nothing",
} {
if !strings.Contains(content, required) {
t.Fatalf("identity pairing start reservation migration is missing %q", required)
}
}
for _, forbidden := range []string{"exchange_token text", "client_secret", "machine_secret", "secret_value", "drop ", "do $"} {
if strings.Contains(content, forbidden) {
t.Fatalf("identity pairing start reservation migration contains forbidden content %q", forbidden)
}
}
}
func TestIdentityPairingStartReservationMigrationSeedsOutstandingPairing(t *testing.T) {
pool := newIdentityMigrationPostgresTestSchema(t)
ctx := context.Background()
for _, migration := range []string{
"../../migrations/0065_identity_configuration_revisions.sql",
"../../migrations/0066_identity_onboarding_exchanges.sql",
} {
applyIdentityMigrationTestFile(t, ctx, pool, migration)
}
revisionID := uuid.NewString()
pairingID := uuid.NewString()
expiresAt := time.Now().UTC().Add(30 * time.Minute).Truncate(time.Microsecond)
if _, err := pool.Exec(ctx, `
INSERT INTO gateway_identity_configuration_revisions (
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url
) VALUES ($1::uuid,'draft','https://auth.test.example','default',
'https://gateway.test.example','https://gateway-web.test.example')`, revisionID); err != nil {
t.Fatalf("seed identity revision for pairing reservation migration: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO gateway_identity_onboarding_exchanges (
id,revision_id,remote_exchange_id,exchange_token_ref,status,remote_version,expires_at
) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,'credentials_saved',4,$5)`,
pairingID, revisionID, uuid.NewString(), "identity-exchange-"+pairingID, expiresAt); err != nil {
t.Fatalf("seed identity exchange for pairing reservation migration: %v", err)
}
applyIdentityMigrationTestFile(t, ctx, pool, identityPairingStartReservationMigration)
var attemptID, state, reservedRevisionID string
var gotExpiresAt, updatedAt time.Time
if err := pool.QueryRow(ctx, `
SELECT attempt_id::text,state,revision_id::text,expires_at,updated_at
FROM gateway_identity_pairing_start_reservation`).Scan(
&attemptID, &state, &reservedRevisionID, &gotExpiresAt, &updatedAt,
); err != nil {
t.Fatalf("read pairing start reservation: %v", err)
}
if attemptID != pairingID || state != "paired" || reservedRevisionID != revisionID ||
!gotExpiresAt.Equal(expiresAt) || updatedAt.IsZero() {
t.Fatalf("unexpected pairing reservation attempt=%q state=%q revision=%q expires=%v updated=%v",
attemptID, state, reservedRevisionID, gotExpiresAt, updatedAt)
}
_, err := pool.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation SET state='starting'`)
requirePairingReservationMigrationCode(t, err, "23514", "starting reservation with revision")
_, err = pool.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation SET revision_id=$1::uuid`, uuid.NewString())
requirePairingReservationMigrationCode(t, err, "23503", "reservation with unknown revision")
}
func requirePairingReservationMigrationCode(t *testing.T, err error, wantCode, operation string) {
t.Helper()
if err == nil {
t.Fatalf("%s unexpectedly satisfied migration constraints", operation)
}
var postgresError *pgconn.PgError
if !errors.As(err, &postgresError) || postgresError.Code != wantCode {
t.Fatalf("%s error=%v, want PostgreSQL code %s", operation, err, wantCode)
}
}

View File

@ -0,0 +1,360 @@
package main
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestSecurityEventSchemaMigrationsDefineCurrentLifecycle(t *testing.T) {
streamPayload, err := os.ReadFile("../../migrations/0063_oidc_security_events.sql")
if err != nil {
t.Fatalf("read security event stream migration: %v", err)
}
streamSQL := string(streamPayload)
for _, statement := range []string{
"CREATE TABLE IF NOT EXISTS gateway_security_event_stream_state",
"CREATE TABLE IF NOT EXISTS gateway_security_event_verification_challenges",
"CREATE INDEX IF NOT EXISTS idx_gateway_security_event_challenges_expiry",
} {
if !strings.Contains(streamSQL, statement) {
t.Fatalf("security event stream migration is missing %q", statement)
}
}
connectionPayload, err := os.ReadFile("../../migrations/0064_security_event_connections.sql")
if err != nil {
t.Fatalf("read security event connection migration: %v", err)
}
connectionSQL := string(connectionPayload)
for _, statement := range []string{
"CREATE TABLE IF NOT EXISTS gateway_security_event_connections",
"management_client_id text",
"management_credential_ref text",
"CREATE TABLE IF NOT EXISTS gateway_security_event_connection_idempotency",
} {
if !strings.Contains(connectionSQL, statement) {
t.Fatalf("security event connection migration is missing %q", statement)
}
}
}
func TestIdentityConfigurationRevisionMigrationDefinesSecretReferencesAndSingleActiveRevision(t *testing.T) {
payload, err := os.ReadFile("../../migrations/0065_identity_configuration_revisions.sql")
if err != nil {
t.Fatal(err)
}
content := string(payload)
for _, required := range []string{
"CREATE TABLE IF NOT EXISTS gateway_identity_configuration_revisions",
"machine_credential_ref text",
"session_encryption_key_ref text",
"security_event_configuration_url text",
"WHERE state = 'active'",
"CHECK (state IN ('draft','validated','active','superseded','failed'))",
} {
if !strings.Contains(content, required) {
t.Fatalf("identity configuration migration is missing %q", required)
}
}
for _, forbidden := range []string{"machine_secret", "client_secret", "exchange_token text", "onboarding_code text"} {
if strings.Contains(strings.ToLower(content), forbidden) {
t.Fatalf("identity configuration migration stores forbidden secret field %q", forbidden)
}
}
}
func TestIdentityOnboardingExchangeMigrationStoresOnlySecretReferences(t *testing.T) {
payload, err := os.ReadFile("../../migrations/0066_identity_onboarding_exchanges.sql")
if err != nil {
t.Fatal(err)
}
content := strings.ToLower(string(payload))
for _, required := range []string{
"create table if not exists gateway_identity_onboarding_exchanges",
"exchange_token_ref text not null",
"cleanup_status text not null default 'none'",
"security_event_credential_handoff_unsafe",
} {
if !strings.Contains(content, required) {
t.Fatalf("identity onboarding migration is missing %q", required)
}
}
for _, forbidden := range []string{"exchange_token text", "onboarding_code", "client_secret", "machine_secret"} {
if strings.Contains(content, forbidden) {
t.Fatalf("identity onboarding migration stores forbidden secret field %q", forbidden)
}
}
}
func TestIdentityOnboardingMigrationDefinesCancellationLifecycle(t *testing.T) {
payload, err := os.ReadFile("../../migrations/0066_identity_onboarding_exchanges.sql")
if err != nil {
t.Fatal(err)
}
content := strings.ToLower(string(payload))
for _, required := range []string{
"'cancelled'",
"cleanup_status text not null default 'none'",
"cleanup_completed_at",
"idx_gateway_identity_onboarding_cleanup",
"last_error_category",
} {
if !strings.Contains(content, required) {
t.Fatalf("identity pairing cancellation migration is missing %q", required)
}
}
for _, forbidden := range []string{"exchange_token text", "client_secret", "machine_secret"} {
if strings.Contains(content, forbidden) {
t.Fatalf("identity pairing cancellation migration stores forbidden secret field %q", forbidden)
}
}
}
func TestIdentityOnboardingMigrationDefinesFinalErrorCategories(t *testing.T) {
payload, err := os.ReadFile("../../migrations/0066_identity_onboarding_exchanges.sql")
if err != nil {
t.Fatal(err)
}
content := strings.ToLower(string(payload))
for _, required := range []string{
"gateway_identity_onboarding_error_category_check",
"security_event_credential_handoff_unsafe",
"security_event_connection_binding_missing",
"security_event_connection_binding_unavailable",
"security_event_connection_binding_invalid",
"security_event_connection_binding_mismatch",
"cleanup_security_event_credential_handoff_unsafe",
} {
if !strings.Contains(content, required) {
t.Fatalf("identity pairing error category migration is missing %q", required)
}
}
}
func TestIdentityPairingErrorCategoriesExecuteAgainstCurrentSchema(t *testing.T) {
pool := newIdentityMigrationPostgresTestSchema(t)
ctx := context.Background()
for _, migration := range []string{
"../../migrations/0065_identity_configuration_revisions.sql",
"../../migrations/0066_identity_onboarding_exchanges.sql",
"../../migrations/0067_identity_secret_cleanup_queue.sql",
"../../migrations/0068_identity_pairing_start_reservation.sql",
} {
applyIdentityMigrationTestFile(t, ctx, pool, migration)
}
revisionID, pairingID := uuid.NewString(), uuid.NewString()
if _, err := pool.Exec(ctx, `
INSERT INTO gateway_identity_configuration_revisions (
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url
) VALUES ($1::uuid,'draft','https://auth.test.example','default',
'https://gateway.test.example','https://gateway-web.test.example')`, revisionID); err != nil {
t.Fatalf("seed identity revision: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO gateway_identity_onboarding_exchanges (
id,revision_id,remote_exchange_id,exchange_token_ref,status,remote_version,expires_at
) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,'credentials_saved',4,now() + interval '30 minutes')`,
pairingID, revisionID, uuid.NewString(), "identity-exchange-"+pairingID); err != nil {
t.Fatalf("seed onboarding exchange: %v", err)
}
for _, category := range []string{
"security_event_credential_handoff_unsafe",
"security_event_connection_binding_missing",
"security_event_connection_binding_unavailable",
"security_event_connection_binding_invalid",
"security_event_connection_binding_mismatch",
"cleanup_security_event_credential_handoff_unsafe",
} {
if _, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
SET last_error_category=$2 WHERE id=$1::uuid`, pairingID, category); err != nil {
t.Fatalf("persist supported error category %q: %v", category, err)
}
}
_, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
SET last_error_category='credential_secret_leaked' WHERE id=$1::uuid`, pairingID)
requireIdentityMigrationCheckViolation(t, err, "unknown upgraded identity pairing category")
}
func TestIdentitySecretCleanupQueueMigrationStoresOnlyReferences(t *testing.T) {
payload, err := os.ReadFile("../../migrations/0067_identity_secret_cleanup_queue.sql")
if err != nil {
t.Fatal(err)
}
content := strings.ToLower(string(payload))
for _, required := range []string{
"create table if not exists gateway_identity_secret_cleanup_queue",
"secret_ref text primary key",
"not_before timestamptz not null",
"status text not null default 'pending'",
"claim_token uuid",
"lease_expires_at timestamptz",
"gateway_identity_secret_cleanup_claim_check",
"idx_gateway_identity_secret_cleanup_due",
} {
if !strings.Contains(content, required) {
t.Fatalf("identity Secret cleanup migration is missing %q", required)
}
}
for _, forbidden := range []string{"secret_value", "client_secret", "machine_secret", "token text"} {
if strings.Contains(content, forbidden) {
t.Fatalf("identity Secret cleanup migration stores forbidden value field %q", forbidden)
}
}
}
func TestIdentityPairingCancellationLifecycleRejectsPartialStates(t *testing.T) {
pool := newIdentityMigrationPostgresTestSchema(t)
ctx := context.Background()
applyIdentityMigrationTestFile(t, ctx, pool, "../../migrations/0065_identity_configuration_revisions.sql")
applyIdentityMigrationTestFile(t, ctx, pool, "../../migrations/0066_identity_onboarding_exchanges.sql")
revisionID, pairingID := uuid.NewString(), uuid.NewString()
if _, err := pool.Exec(ctx, `
INSERT INTO gateway_identity_configuration_revisions (
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url
) VALUES ($1::uuid,'draft','https://auth.test.example','default',
'https://gateway.test.example','https://gateway-web.test.example')`, revisionID); err != nil {
t.Fatalf("seed legacy identity revision: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO gateway_identity_onboarding_exchanges (
id,revision_id,remote_exchange_id,exchange_token_ref,status,remote_version,expires_at,last_error_category
) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,'credentials_saved',4,now() + interval '30 minutes','pairing_step_failed')`,
pairingID, revisionID, uuid.NewString(), "identity-exchange-"+pairingID); err != nil {
t.Fatalf("seed legacy onboarding exchange: %v", err)
}
var status, cleanupStatus, errorCategory string
var cancelledAt, cleanupCompletedAt *time.Time
if err := pool.QueryRow(ctx, `
SELECT status,cleanup_status,last_error_category,cancelled_at,cleanup_completed_at
FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid`, pairingID).
Scan(&status, &cleanupStatus, &errorCategory, &cancelledAt, &cleanupCompletedAt); err != nil {
t.Fatalf("read migrated onboarding exchange: %v", err)
}
if status != "credentials_saved" || cleanupStatus != "none" || errorCategory != "pairing_step_failed" ||
cancelledAt != nil || cleanupCompletedAt != nil {
t.Fatalf("unexpected migrated exchange status=%q cleanup=%q error=%q cancelled=%v completed=%v",
status, cleanupStatus, errorCategory, cancelledAt, cleanupCompletedAt)
}
_, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges SET status='cancelled' WHERE id=$1::uuid`, pairingID)
requireIdentityMigrationCheckViolation(t, err, "cancelled status without cleanup intent")
if _, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
SET status='cancelled',cleanup_status='pending',cancelled_at=now()
WHERE id=$1::uuid`, pairingID); err != nil {
t.Fatalf("persist valid pending cleanup lifecycle: %v", err)
}
_, err = pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
SET cleanup_status='completed' WHERE id=$1::uuid`, pairingID)
requireIdentityMigrationCheckViolation(t, err, "completed cleanup without completion timestamp")
if _, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
SET cleanup_status='completed',cleanup_completed_at=now()
WHERE id=$1::uuid`, pairingID); err != nil {
t.Fatalf("persist valid completed cleanup lifecycle: %v", err)
}
_, err = pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
SET last_error_category='still-invalid' WHERE id=$1::uuid`, pairingID)
requireIdentityMigrationCheckViolation(t, err, "invalid post-migration error category")
if err := pool.QueryRow(ctx, `SELECT status,cleanup_status FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid`, pairingID).
Scan(&status, &cleanupStatus); err != nil {
t.Fatalf("read completed cleanup lifecycle: %v", err)
}
if status != "cancelled" || cleanupStatus != "completed" {
t.Fatalf("completed cleanup lifecycle status=%q cleanup=%q", status, cleanupStatus)
}
}
func applyIdentityMigrationTestFile(t *testing.T, ctx context.Context, pool *pgxpool.Pool, path string) {
t.Helper()
payload, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read migration %s: %v", path, err)
}
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin migration %s: %v", path, err)
}
if _, err := tx.Exec(ctx, string(payload)); err != nil {
_ = tx.Rollback(ctx)
t.Fatalf("execute migration %s: %v", path, err)
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("commit migration %s: %v", path, err)
}
}
func requireIdentityMigrationCheckViolation(t *testing.T, err error, operation string) {
t.Helper()
if err == nil {
t.Fatalf("%s unexpectedly satisfied migration constraints", operation)
}
var postgresError *pgconn.PgError
if !errors.As(err, &postgresError) || postgresError.Code != "23514" {
t.Fatalf("%s error=%v, want PostgreSQL check violation", operation, err)
}
}
func newIdentityMigrationPostgresTestSchema(t *testing.T) *pgxpool.Pool {
t.Helper()
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run identity migration PostgreSQL integration tests")
}
ctx := context.Background()
admin, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatalf("connect identity migration test database: %v", err)
}
var databaseName string
if err := admin.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil {
admin.Close()
t.Fatalf("read identity migration test database name: %v", err)
}
if !strings.Contains(strings.ToLower(databaseName), "test") {
admin.Close()
t.Fatalf("refusing to use non-test database %q", databaseName)
}
schemaName := "gateway_identity_migration_" + strings.ReplaceAll(uuid.NewString(), "-", "")
schemaIdentifier := pgx.Identifier{schemaName}.Sanitize()
if _, err := admin.Exec(ctx, `CREATE SCHEMA `+schemaIdentifier); err != nil {
admin.Close()
t.Fatalf("create identity migration test schema: %v", err)
}
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
_, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`)
admin.Close()
t.Fatalf("parse identity migration test database URL: %v", err)
}
config.ConnConfig.RuntimeParams["search_path"] = schemaName
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
_, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`)
admin.Close()
t.Fatalf("connect identity migration test schema: %v", err)
}
t.Cleanup(func() {
pool.Close()
if _, err := admin.Exec(context.Background(), `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`); err != nil {
t.Errorf("drop identity migration test schema: %v", err)
}
admin.Close()
})
return pool
}

File diff suppressed because it is too large Load Diff

View File

@ -1028,6 +1028,70 @@ definitions:
billing:
$ref: '#/definitions/httpapi.desktopBillingConfigResponse'
type: object
httpapi.identityConfigurationView:
properties:
active:
$ref: '#/definitions/identity.Revision'
draft:
$ref: '#/definitions/identity.Revision'
pairing:
$ref: '#/definitions/identity.PairingExchange'
previous:
$ref: '#/definitions/identity.Revision'
runtime:
$ref: '#/definitions/httpapi.identityRuntimeStatus'
type: object
httpapi.identityPolicyPatch:
properties:
jitEnabled:
type: boolean
legacyJwtEnabled:
type: boolean
localTenantKey:
type: string
rolePrefix:
type: string
sessionAbsoluteSeconds:
type: integer
sessionIdleSeconds:
type: integer
sessionRefreshSeconds:
type: integer
type: object
httpapi.identityRuntimeStatus:
properties:
enabled:
type: boolean
jit:
type: string
lastAuditId:
type: string
lastErrorCategory:
type: string
lastTraceId:
type: string
login:
type: string
revisionId:
type: string
sessionRevocation:
type: string
tokenIntrospection:
type: string
type: object
httpapi.publicIdentityConfiguration:
properties:
enabled:
type: boolean
loginUrl:
type: string
logoutUrl:
type: string
oidcLogin:
type: boolean
status:
type: string
type: object
httpapi.updatePlatformDynamicPriorityRequest:
properties:
dynamicPriority:
@ -1073,6 +1137,175 @@ definitions:
example: manual recharge
type: string
type: object
identity.PairingCleanupStatus:
enum:
- none
- pending
- completed
type: string
x-enum-varnames:
- PairingCleanupNone
- PairingCleanupPending
- PairingCleanupCompleted
identity.PairingExchange:
properties:
authCenterAuditId:
type: string
cancelledAt:
type: string
cleanupCompletedAt:
type: string
cleanupStatus:
$ref: '#/definitions/identity.PairingCleanupStatus'
createdAt:
type: string
expiresAt:
type: string
id:
type: string
lastErrorCategory:
type: string
lastTraceId:
type: string
remoteExchangeId:
type: string
remoteVersion:
type: integer
revisionId:
type: string
status:
$ref: '#/definitions/identity.PairingStatus'
updatedAt:
type: string
version:
type: integer
type: object
identity.PairingInput:
properties:
authCenterUrl:
type: string
legacyJwtEnabled:
type: boolean
localTenantKey:
type: string
onboardingCode:
type: string
publicBaseUrl:
type: string
webBaseUrl:
type: string
type: object
identity.PairingStatus:
enum:
- metadata_pending
- preparing
- ready
- credentials_saved
- completed
- failed
- expired
- cancelled
type: string
x-enum-varnames:
- PairingMetadataPending
- PairingPreparing
- PairingReady
- PairingCredentialsSaved
- PairingCompleted
- PairingFailed
- PairingExpired
- PairingCancelled
identity.Revision:
properties:
activatedAt:
type: string
applicationId:
type: string
audience:
type: string
authCenterUrl:
type: string
browserClientId:
type: string
capabilities:
items:
type: string
type: array
createdAt:
type: string
id:
type: string
issuer:
type: string
jitEnabled:
type: boolean
lastAuditId:
type: string
lastErrorCategory:
type: string
lastTraceId:
type: string
legacyJwtEnabled:
type: boolean
localTenantKey:
type: string
machineClientId:
type: string
publicBaseUrl:
type: string
rolePrefix:
type: string
schemaVersion:
type: integer
scopes:
items:
type: string
type: array
securityEventAudience:
type: string
securityEventConfigurationUrl:
type: string
securityEventIssuer:
type: string
sessionAbsoluteSeconds:
type: integer
sessionIdleSeconds:
type: integer
sessionRefreshSeconds:
type: integer
sessionRevocation:
type: boolean
state:
$ref: '#/definitions/identity.RevisionState'
supersededAt:
type: string
tenantId:
type: string
tokenIntrospection:
type: boolean
updatedAt:
type: string
validatedAt:
type: string
version:
type: integer
webBaseUrl:
type: string
type: object
identity.RevisionState:
enum:
- draft
- validated
- active
- superseded
- failed
type: string
x-enum-varnames:
- RevisionDraft
- RevisionValidated
- RevisionActive
- RevisionSuperseded
- RevisionFailed
store.APIKey:
properties:
createdAt:
@ -4604,6 +4837,487 @@ paths:
summary: 更新文件存储设置
tags:
- system
/api/admin/system/identity/configuration:
get:
description: 返回 Active、Draft、Previous Revision、配对进度和脱敏运行时健康状态。
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/httpapi.identityConfigurationView'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"503":
description: Service Unavailable
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 获取统一认证配置
tags:
- identity
/api/admin/system/identity/disable:
post:
description: 将 Revision 转为只读 Superseded 审计历史,清理 BFF Session并继续允许本地管理登录重新接入必须使用新的接入码。
parameters:
- description: 幂等键
in: header
name: Idempotency-Key
required: true
type: string
- description: 当前 Active Revision ETag
in: header
name: If-Match
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/identity.Revision'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"409":
description: Conflict
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"412":
description: Precondition Failed
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"428":
description: Precondition Required
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 禁用统一认证
tags:
- identity
/api/admin/system/identity/pairings:
post:
consumes:
- application/json
description: 使用一次性接入码创建 Draft 和异步 Exchange。接入码仅允许出现在请求 Body。
parameters:
- description: 幂等键
in: header
name: Idempotency-Key
required: true
type: string
- description: 首次配对固定为 W/\
in: header
name: If-Match
required: true
type: string
- description: 配对参数
in: body
name: body
required: true
schema:
$ref: '#/definitions/identity.PairingInput'
produces:
- application/json
responses:
"202":
description: Accepted
schema:
$ref: '#/definitions/identity.PairingExchange'
"400":
description: Bad Request
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"409":
description: Conflict
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"428":
description: Precondition Required
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 启动统一认证配对
tags:
- identity
/api/admin/system/identity/pairings/{pairingID}:
get:
parameters:
- description: 配对 ID
in: path
name: pairingID
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/identity.PairingExchange'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"404":
description: Not Found
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 查询统一认证配对状态
tags:
- identity
/api/admin/system/identity/pairings/{pairingID}/cancel:
post:
description: 原子封存未激活 Revision并异步清理由该 Revision 拥有的临时 Secret 与 SSF 连接。不会撤销已经完成的远端
Exchange下一次凭据交付会轮换机器凭据。
parameters:
- description: 配对 ID
in: path
name: pairingID
required: true
type: string
- description: 幂等键
in: header
name: Idempotency-Key
required: true
type: string
- description: 当前 Pairing ETag
in: header
name: If-Match
required: true
type: string
produces:
- application/json
responses:
"202":
description: Accepted
schema:
$ref: '#/definitions/identity.PairingExchange'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"404":
description: Not Found
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"409":
description: Conflict
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"412":
description: Precondition Failed
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"428":
description: Precondition Required
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 放弃本地统一认证配对
tags:
- identity
/api/admin/system/identity/pairings/{pairingID}/retire-conflicting-security-event:
post:
description: 仅当指定 Pairing 仍因 owner 冲突停在 credentials_saved 时,退役不属于该 Revision
的旧连接;陈旧请求不会退役当前 Pairing 已创建的新连接。
parameters:
- description: 配对 ID
in: path
name: pairingID
required: true
type: string
- description: 幂等键
in: header
name: Idempotency-Key
required: true
type: string
- description: 当前 Pairing ETag
in: header
name: If-Match
required: true
type: string
produces:
- application/json
responses:
"202":
description: Accepted
schema:
$ref: '#/definitions/identity.PairingExchange'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"404":
description: Not Found
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"409":
description: Conflict
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"412":
description: Precondition Failed
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"428":
description: Precondition Required
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 安全退役阻塞配对的旧 SSF 连接
tags:
- identity
/api/admin/system/identity/revisions/{revisionID}:
patch:
consumes:
- application/json
parameters:
- description: Revision ID
in: path
name: revisionID
required: true
type: string
- description: 幂等键
in: header
name: Idempotency-Key
required: true
type: string
- description: 当前 Revision ETag
in: header
name: If-Match
required: true
type: string
- description: Gateway 本地策略
in: body
name: body
required: true
schema:
$ref: '#/definitions/httpapi.identityPolicyPatch'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/identity.Revision'
"400":
description: Bad Request
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"412":
description: Precondition Failed
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"428":
description: Precondition Required
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 修改统一认证 Draft 策略
tags:
- identity
/api/admin/system/identity/revisions/{revisionID}/activate:
post:
description: 在完整候选 Runtime 验证成功且本地 Break-glass Manager 可用后原子热切换。
parameters:
- description: Revision ID
in: path
name: revisionID
required: true
type: string
- description: 幂等键
in: header
name: Idempotency-Key
required: true
type: string
- description: 当前 Revision ETag
in: header
name: If-Match
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/identity.Revision'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"409":
description: Conflict
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"412":
description: Precondition Failed
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"428":
description: Precondition Required
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 激活统一认证 Revision
tags:
- identity
/api/admin/system/identity/revisions/{revisionID}/rollback:
post:
description: 当前远端 OAuth/SSF 资源未版本化,接口会拒绝直接回滚并要求禁用后使用新接入码完成交接。
parameters:
- description: Revision ID
in: path
name: revisionID
required: true
type: string
- description: 幂等键
in: header
name: Idempotency-Key
required: true
type: string
- description: 当前 Revision ETag
in: header
name: If-Match
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/identity.Revision'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"409":
description: Conflict
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"412":
description: Precondition Failed
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"428":
description: Precondition Required
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 请求恢复历史统一认证 Revision
tags:
- identity
/api/admin/system/identity/revisions/{revisionID}/validate:
post:
parameters:
- description: Revision ID
in: path
name: revisionID
required: true
type: string
- description: 幂等键
in: header
name: Idempotency-Key
required: true
type: string
- description: 当前 Revision ETag
in: header
name: If-Match
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/identity.Revision'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"412":
description: Precondition Failed
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"428":
description: Precondition Required
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"502":
description: Bad Gateway
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 验证统一认证 Revision
tags:
- identity
/api/admin/tenants:
get:
description: 管理端返回网关租户列表。
@ -5474,7 +6188,7 @@ paths:
post:
consumes:
- application/json
description: 使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。
description: 使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。非本地身份模式只允许本地应急 Manager 登录。
parameters:
- description: 登录请求account 可为用户名或邮箱
in: body
@ -6183,6 +6897,19 @@ paths:
summary: 获取公开客户端自定义设置
tags:
- system
/api/v1/public/identity:
get:
description: 返回 Web 运行时需要的启用状态和非敏感登录、退出入口,不返回 Client、Secret 或内部标识。
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/httpapi.publicIdentityConfiguration'
summary: 获取公开统一认证状态
tags:
- identity
/api/v1/public/skills/ai-gateway-ops-management/download:
get:
description: 下载可交给 Agent 使用的 ai-gateway-ops-management ZIP 包。
@ -6330,6 +7057,57 @@ paths:
summary: 创建 OpenAI Responses
tags:
- responses
/api/v1/security-events/ssf:
post:
consumes:
- application/secevent+jwt
description: Optional endpoint. It validates a stream-specific Bearer before
parsing and verifying an ES256 SET. A committed event and a duplicate jti
both return an empty 202 response.
parameters:
- description: Bearer stream-specific-secret
in: header
name: Authorization
required: true
type: string
- description: Compact signed Security Event Token
in: body
name: set
required: true
schema:
type: string
produces:
- application/json
responses:
"202":
description: Accepted
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"401":
description: Unauthorized
schema:
additionalProperties:
type: string
type: object
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
"503":
description: Service Unavailable
schema:
additionalProperties:
type: string
type: object
summary: Receive an SSF Security Event Token
tags:
- Security Events
/api/v1/song/generations:
post:
consumes:

View File

@ -9,6 +9,7 @@ require (
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.2
github.com/riverqueue/river v0.24.0
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.24.0

View File

@ -19,6 +19,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=

View File

@ -22,7 +22,9 @@ import (
type Permission string
const (
OIDCSessionCookieName = "easyai_gateway_oidc_session"
OIDCSessionCookieName = "easyai_gateway_oidc_session"
localBreakGlassTokenPurpose = "local_break_glass_manager"
legacyAccessTokenPurpose = "legacy_access"
PermissionPublic Permission = "public"
PermissionBasic Permission = "basic"
@ -50,6 +52,9 @@ type User struct {
APIKeyPrefix string `json:"apiKeyPrefix,omitempty"`
APIKeyScopes []string `json:"apiKeyScopes,omitempty"`
TokenExpiresAt time.Time `json:"-"`
TokenIssuedAt time.Time `json:"-"`
Issuer string `json:"-"`
TokenPurpose string `json:"-"`
}
type contextKey string
@ -71,16 +76,19 @@ func NewRequestAuthError(status int, code, message string) error {
}
type Authenticator struct {
JWTSecret string
ServerMainBaseURL string
ServerMainInternalToken string
ServerMainInternalKey string
ServerMainInternalSecret string
HTTPClient *http.Client
LocalAPIKeyVerifier func(ctx context.Context, apiKey string) (*User, error)
OIDCVerifier *OIDCVerifier
OIDCSessionResolver func(ctx context.Context, sessionID string) (*User, error)
LegacyJWTEnabled bool
JWTSecret string
ServerMainBaseURL string
ServerMainInternalToken string
ServerMainInternalKey string
ServerMainInternalSecret string
HTTPClient *http.Client
LocalAPIKeyVerifier func(ctx context.Context, apiKey string) (*User, error)
OIDCVerifier *OIDCVerifier
OIDCVerifierProvider func() *OIDCVerifier
OIDCSessionResolver func(ctx context.Context, sessionID string) (*User, error)
OIDCSessionResolverProvider func(ctx context.Context, sessionID string) (*User, error)
LegacyJWTEnabled bool
LegacyJWTEnabledProvider func() bool
}
func New(jwtSecret string, serverMainBaseURL string, internalToken string) *Authenticator {
@ -137,23 +145,36 @@ func (a *Authenticator) Require(permission Permission, next http.Handler) http.H
}
func (a *Authenticator) Authenticate(r *http.Request) (*User, error) {
token := extractBearer(r.Header.Get("Authorization"))
if token == "" {
token = strings.TrimSpace(r.Header.Get("x-comfy-api-key"))
}
if token == "" {
token = strings.TrimSpace(r.Header.Get("x-goog-api-key"))
}
if token == "" {
token = strings.TrimSpace(r.URL.Query().Get("key"))
var token string
if authorization := strings.TrimSpace(r.Header.Get("Authorization")); authorization != "" {
token = extractBearer(authorization)
if token == "" {
return nil, ErrUnauthorized
}
} else if value := strings.TrimSpace(r.Header.Get("x-comfy-api-key")); value != "" {
token = value
} else if value := strings.TrimSpace(r.Header.Get("x-goog-api-key")); value != "" {
token = value
} else if value := strings.TrimSpace(r.URL.Query().Get("key")); value != "" {
// Query credentials are retained only for API compatibility with
// providers that use `?key=sk-*`. Bearer/OIDC tokens in URLs would leak
// through browser history, reverse-proxy logs, and diagnostics.
if !strings.HasPrefix(value, "sk-") {
return nil, ErrUnauthorized
}
token = value
}
if token == "" {
if cookie, err := r.Cookie(OIDCSessionCookieName); err == nil {
sessionID := strings.TrimSpace(cookie.Value)
if sessionID == "" || a.OIDCSessionResolver == nil {
resolver := a.OIDCSessionResolver
if a.OIDCSessionResolverProvider != nil {
resolver = a.OIDCSessionResolverProvider
}
if sessionID == "" || resolver == nil {
return nil, ErrUnauthorized
}
return a.OIDCSessionResolver(r.Context(), sessionID)
return resolver(r.Context(), sessionID)
}
}
if token == "" {
@ -166,18 +187,33 @@ func (a *Authenticator) Authenticate(r *http.Request) (*User, error) {
if algorithm == "RS256" || algorithm == "ES256" {
return a.AuthenticateOIDCAccessToken(r.Context(), token)
}
if !a.LegacyJWTEnabled {
return nil, ErrUnauthorized
user, err := a.verifyJWT(token)
if err != nil {
return nil, err
}
return a.verifyJWT(token)
if a.legacyJWTEnabled() || isLocalBreakGlassManager(user) {
return user, nil
}
return nil, ErrUnauthorized
}
func (a *Authenticator) AuthenticateOIDCAccessToken(ctx context.Context, token string) (*User, error) {
algorithm := jwtAlgorithm(token)
if a.OIDCVerifier == nil || algorithm != "RS256" && algorithm != "ES256" {
verifier := a.OIDCVerifier
if a.OIDCVerifierProvider != nil {
verifier = a.OIDCVerifierProvider()
}
if verifier == nil || algorithm != "RS256" && algorithm != "ES256" {
return nil, ErrUnauthorized
}
return a.OIDCVerifier.Verify(ctx, token)
return verifier.Verify(ctx, token)
}
func (a *Authenticator) legacyJWTEnabled() bool {
if a.LegacyJWTEnabledProvider != nil {
return a.LegacyJWTEnabledProvider()
}
return a.LegacyJWTEnabled
}
func (a *Authenticator) verifyJWT(tokenString string) (*User, error) {
@ -213,6 +249,7 @@ func (a *Authenticator) verifyJWT(tokenString string) (*User, error) {
APIKeyName: stringClaim(claims, "apiKeyName"),
APIKeyPrefix: stringClaim(claims, "apiKeyPrefix"),
APIKeyScopes: stringSliceClaim(claims, "apiKeyScopes"),
TokenPurpose: stringClaim(claims, "tokenPurpose"),
}
if user.Source == "" {
user.Source = "gateway"
@ -228,6 +265,10 @@ func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) {
ttl = time.Hour
}
now := time.Now()
tokenPurpose := legacyAccessTokenPurpose
if user.Source == "gateway" && hasManagerRole(user.Roles) {
tokenPurpose = localBreakGlassTokenPurpose
}
claims := jwt.MapClaims{
"sub": user.ID,
"username": user.Username,
@ -244,6 +285,7 @@ func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) {
"apiKeyName": user.APIKeyName,
"apiKeyPrefix": user.APIKeyPrefix,
"apiKeyScopes": user.APIKeyScopes,
"tokenPurpose": tokenPurpose,
"iat": now.Unix(),
"exp": now.Add(ttl).Unix(),
}
@ -251,6 +293,19 @@ func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) {
return token.SignedString([]byte(a.JWTSecret))
}
func isLocalBreakGlassManager(user *User) bool {
return user != nil && user.Source == "gateway" && user.TokenPurpose == localBreakGlassTokenPurpose && hasManagerRole(user.Roles)
}
func hasManagerRole(roles []string) bool {
for _, role := range roles {
if role == "manager" || role == "admin" {
return true
}
}
return false
}
func (a *Authenticator) verifyAPIKey(ctx context.Context, apiKey string) (*User, error) {
if a.LocalAPIKeyVerifier != nil {
user, err := a.LocalAPIKeyVerifier(ctx, apiKey)

View File

@ -5,12 +5,14 @@ import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net"
"net/http"
"net/url"
"strings"
@ -24,16 +26,34 @@ const maxOIDCResponseBytes = 1 << 20
const defaultOIDCHTTPTimeout = 10 * time.Second
type OIDCConfig struct {
Issuer string
Audience string
TenantID string
RolePrefix string
RequiredScopes []string
JWKSCacheTTL time.Duration
IntrospectionEnabled bool
IntrospectionClientID string
IntrospectionClientSecret string
HTTPClient *http.Client
AppEnv string
Issuer string
Audience string
TenantID string
RolePrefix string
RequiredScopes []string
JWKSCacheTTL time.Duration
IntrospectionEnabled bool
IntrospectionClientID string
IntrospectionClientSecret string
IntrospectionCredentialProvider func(context.Context) (string, []byte, error)
HTTPClient *http.Client
SecurityEventEvaluator func(context.Context, OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error)
IntrospectionObserver func(string)
JWKSRefreshFailureObserver func()
}
type OIDCSecurityEventIdentity struct {
Issuer string
TenantID string
Subject string
IssuedAt time.Time
}
type OIDCSecurityEventEvaluation struct {
Enabled bool
Revoked bool
RequireIntrospection bool
}
type OIDCVerifier struct {
@ -72,10 +92,11 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) {
config.Audience = strings.TrimSpace(config.Audience)
config.TenantID = strings.TrimSpace(config.TenantID)
config.RolePrefix = strings.TrimSpace(config.RolePrefix)
if err := validatePublicURL(config.Issuer); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" {
if err := validatePublicURL(config.Issuer, config.AppEnv); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" {
return nil, errors.New("issuer, audience, tenant and role prefix are required")
}
if config.IntrospectionEnabled && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
if config.IntrospectionEnabled && config.IntrospectionCredentialProvider == nil &&
(strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
return nil, errors.New("introspection client credentials are required when introspection is enabled")
}
if config.JWKSCacheTTL <= 0 {
@ -83,13 +104,30 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) {
}
client := config.HTTPClient
if client == nil {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
transport.DisableCompression = true
client = &http.Client{Timeout: defaultOIDCHTTPTimeout, CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}}
}, Transport: transport}
}
return &OIDCVerifier{config: config, client: client, keys: map[string]any{}}, nil
}
// ValidateConfiguration eagerly validates Discovery, JWKS and, when enabled,
// the RFC 7662 endpoint and machine credential before a runtime is activated.
func (v *OIDCVerifier) ValidateConfiguration(ctx context.Context) error {
if err := v.refresh(ctx, true); err != nil {
return err
}
if v.config.IntrospectionEnabled || v.config.SecurityEventEvaluator != nil {
if _, err := v.introspect(ctx, "identity-configuration-probe"); err != nil {
return err
}
}
return nil
}
func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
parser := jwt.NewParser(jwt.WithValidMethods([]string{"RS256", "ES256"}))
unverified, _, err := parser.ParseUnverified(raw, jwt.MapClaims{})
@ -125,6 +163,7 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
if _, ok := numericDateClaim(claims["nbf"]); !ok {
return nil, oidcUnauthorized("nbf is missing", nil)
}
issuedAt, hasIssuedAt := numericDateClaim(claims["iat"])
scopes := scopeClaims(claims)
if !containsAll(scopes, v.config.RequiredScopes) {
return nil, oidcUnauthorized("required scope is missing", nil)
@ -136,7 +175,34 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
if len(roles) == 0 {
return nil, oidcUnauthorized("mapped role is missing", nil)
}
if v.config.IntrospectionEnabled {
if v.config.SecurityEventEvaluator != nil {
evaluation, evaluateErr := v.config.SecurityEventEvaluator(ctx, OIDCSecurityEventIdentity{
Issuer: v.config.Issuer, TenantID: v.config.TenantID, Subject: stringClaim(claims, "sub"), IssuedAt: issuedAt,
})
if evaluateErr != nil {
return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SECURITY_EVENT_STATE_UNAVAILABLE", "认证撤销状态暂时不可用")
}
if evaluation.Enabled && !hasIssuedAt {
return nil, oidcUnauthorized("iat is required when security events are enabled", nil)
}
if evaluation.Revoked {
return nil, oidcUnauthorized("token was issued before the revocation watermark", nil)
}
if evaluation.RequireIntrospection {
active, introspectionErr := v.introspect(ctx, raw)
if introspectionErr != nil {
return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_INTROSPECTION_UNAVAILABLE", "认证中心内省暂时不可用")
}
if !active {
return nil, oidcUnauthorized("token is inactive", nil)
}
} else if !evaluation.Enabled && v.config.IntrospectionEnabled {
active, introspectionErr := v.introspect(ctx, raw)
if introspectionErr != nil || !active {
return nil, oidcUnauthorized("token is inactive", introspectionErr)
}
}
} else if v.config.IntrospectionEnabled {
active, err := v.introspect(ctx, raw)
if err != nil || !active {
return nil, oidcUnauthorized("token is inactive", err)
@ -148,7 +214,7 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
}
return &User{
ID: stringClaim(claims, "sub"), Username: username, Roles: roles,
TenantID: v.config.TenantID, Source: "oidc", TokenExpiresAt: expiresAt,
TenantID: v.config.TenantID, Source: "oidc", TokenExpiresAt: expiresAt, TokenIssuedAt: issuedAt, Issuer: v.config.Issuer,
}, nil
}
@ -161,6 +227,9 @@ func oidcUnauthorized(reason string, cause error) error {
func (v *OIDCVerifier) key(ctx context.Context, kid string) (any, error) {
if err := v.refresh(ctx, false); err != nil {
if v.config.JWKSRefreshFailureObserver != nil {
v.config.JWKSRefreshFailureObserver()
}
return nil, err
}
v.mu.Lock()
@ -170,6 +239,9 @@ func (v *OIDCVerifier) key(ctx context.Context, kid string) (any, error) {
return key, nil
}
if err := v.refresh(ctx, true); err != nil {
if v.config.JWKSRefreshFailureObserver != nil {
v.config.JWKSRefreshFailureObserver()
}
return nil, err
}
v.mu.Lock()
@ -192,10 +264,10 @@ func (v *OIDCVerifier) refresh(ctx context.Context, force bool) error {
if err := v.fetchJSON(ctx, discoveryURL, &discovery); err != nil {
return err
}
if discovery.Issuer != v.config.Issuer || validatePublicURL(discovery.JWKSURI) != nil {
if discovery.Issuer != v.config.Issuer || validatePublicURL(discovery.JWKSURI, v.config.AppEnv) != nil {
return errors.New("OIDC discovery metadata is invalid")
}
if v.config.IntrospectionEnabled && validatePublicURL(discovery.IntrospectionEndpoint) != nil {
if (v.config.IntrospectionEnabled || v.config.SecurityEventEvaluator != nil) && validatePublicURL(discovery.IntrospectionEndpoint, v.config.AppEnv) != nil {
return errors.New("OIDC introspection metadata is invalid")
}
var set jsonWebKeySet
@ -222,6 +294,12 @@ func (v *OIDCVerifier) refresh(ctx context.Context, force bool) error {
}
func (v *OIDCVerifier) introspect(ctx context.Context, raw string) (bool, error) {
outcome := "failed"
defer func() {
if v.config.IntrospectionObserver != nil {
v.config.IntrospectionObserver(outcome)
}
}()
v.mu.Lock()
endpoint := v.introspectionEndpoint
v.mu.Unlock()
@ -235,7 +313,19 @@ func (v *OIDCVerifier) introspect(ctx context.Context, raw string) (bool, error)
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
request.SetBasicAuth(v.config.IntrospectionClientID, v.config.IntrospectionClientSecret)
clientID := v.config.IntrospectionClientID
secret := []byte(v.config.IntrospectionClientSecret)
if v.config.IntrospectionCredentialProvider != nil {
clientID, secret, err = v.config.IntrospectionCredentialProvider(ctx)
if err != nil {
return false, err
}
}
defer clear(secret)
if strings.TrimSpace(clientID) == "" || len(secret) < 16 {
return false, errors.New("OIDC introspection credential is unavailable")
}
request.SetBasicAuth(clientID, string(secret))
response, err := v.client.Do(request)
if err != nil {
return false, err
@ -252,6 +342,11 @@ func (v *OIDCVerifier) introspect(ctx context.Context, raw string) (bool, error)
if err := decoder.Decode(&result); err != nil {
return false, errors.New("OIDC introspection response is invalid")
}
if result.Active {
outcome = "active"
} else {
outcome = "inactive"
}
return result.Active, nil
}
@ -312,7 +407,7 @@ func decodeBigInt(value string) (*big.Int, error) {
return new(big.Int).SetBytes(payload), nil
}
func validatePublicURL(raw string) error {
func validatePublicURL(raw, appEnv string) error {
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return errors.New("invalid URL")
@ -320,12 +415,30 @@ func validatePublicURL(raw string) error {
if parsed.Scheme == "https" {
return nil
}
if parsed.Scheme == "http" && (parsed.Hostname() == "127.0.0.1" || parsed.Hostname() == "localhost") {
if parsed.Scheme == "http" && isLocalOIDCEnvironment(appEnv) && isLoopbackOIDCHost(parsed.Hostname()) {
return nil
}
return errors.New("URL must use HTTPS")
}
func isLocalOIDCEnvironment(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "development", "dev", "local", "test":
return true
default:
return false
}
}
func isLoopbackOIDCHost(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
if value == "localhost" {
return true
}
ip := net.ParseIP(value)
return ip != nil && ip.IsLoopback()
}
func numericDateClaim(value any) (time.Time, bool) {
switch typed := value.(type) {
case float64:

View File

@ -19,6 +19,7 @@ var ErrOIDCInvalidGrant = errors.New("OIDC refresh token is invalid")
var errOIDCResponseTooLarge = errors.New("OIDC response exceeds size limit")
type OIDCPublicClientConfig struct {
AppEnv string
Issuer string
ClientID string
RedirectURI string
@ -64,12 +65,18 @@ func NewOIDCPublicClient(config OIDCPublicClientConfig) (*OIDCPublicClient, erro
return nil, errors.New("offline_access is not allowed for Gateway browser sessions")
}
}
if validatePublicURL(config.Issuer) != nil || config.ClientID == "" || validatePublicURL(config.RedirectURI) != nil || validatePublicURL(config.PostLogoutRedirectURI) != nil {
if validatePublicURL(config.Issuer, config.AppEnv) != nil || config.ClientID == "" || validatePublicURL(config.RedirectURI, config.AppEnv) != nil || validatePublicURL(config.PostLogoutRedirectURI, config.AppEnv) != nil {
return nil, errors.New("issuer, public client id and exact redirect URLs are required")
}
return &OIDCPublicClient{config: config, client: newOIDCHTTPClient(config.HTTPClient)}, nil
}
// ValidateConfiguration eagerly checks public-client Discovery metadata.
func (c *OIDCPublicClient) ValidateConfiguration(ctx context.Context) error {
_, _, err := c.configuration(ctx)
return err
}
func (c *OIDCPublicClient) AuthorizationURL(ctx context.Context, state, nonce, pkceVerifier string) (string, error) {
if strings.TrimSpace(state) == "" || strings.TrimSpace(nonce) == "" || !validPKCEVerifier(pkceVerifier) {
return "", errors.New("state, nonce and PKCE verifier are required")
@ -214,9 +221,9 @@ func (c *OIDCPublicClient) configuration(ctx context.Context) (*oauth2.Config, o
}
var metadata oidcClientDiscovery
if err := provider.Claims(&metadata); err != nil || metadata.Issuer != c.config.Issuer ||
validatePublicURL(metadata.AuthorizationEndpoint) != nil || validatePublicURL(metadata.TokenEndpoint) != nil || validatePublicURL(metadata.JWKSURI) != nil ||
metadata.RevocationEndpoint != "" && validatePublicURL(metadata.RevocationEndpoint) != nil ||
metadata.EndSessionEndpoint != "" && validatePublicURL(metadata.EndSessionEndpoint) != nil {
validatePublicURL(metadata.AuthorizationEndpoint, c.config.AppEnv) != nil || validatePublicURL(metadata.TokenEndpoint, c.config.AppEnv) != nil || validatePublicURL(metadata.JWKSURI, c.config.AppEnv) != nil ||
metadata.RevocationEndpoint != "" && validatePublicURL(metadata.RevocationEndpoint, c.config.AppEnv) != nil ||
metadata.EndSessionEndpoint != "" && validatePublicURL(metadata.EndSessionEndpoint, c.config.AppEnv) != nil {
return nil, oidcClientDiscovery{}, errors.New("OIDC discovery metadata is invalid")
}
endpoint := provider.Endpoint()

View File

@ -57,12 +57,16 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T
issuer = server.URL
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
AppEnv: "test",
Issuer: issuer, ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/gateway-api/api/v1/auth/oidc/callback",
PostLogoutRedirectURI: "https://gateway.example.com/", Scopes: []string{"openid", "profile", "gateway.access"}, HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
if err := client.ValidateConfiguration(context.Background()); err != nil {
t.Fatalf("ValidateConfiguration() error = %v", err)
}
authorizationURL, err := client.AuthorizationURL(context.Background(), "state", "nonce", pkceVerifier)
if err != nil {
t.Fatal(err)
@ -81,6 +85,7 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T
func TestOIDCPublicClientRejectsOfflineAccess(t *testing.T) {
_, err := NewOIDCPublicClient(OIDCPublicClientConfig{
AppEnv: "production",
Issuer: "https://auth.example.com", ClientID: "gateway-public",
RedirectURI: "https://gateway.example.com/api/v1/auth/oidc/callback",
PostLogoutRedirectURI: "https://gateway.example.com/",
@ -116,6 +121,7 @@ func TestOIDCPublicClientVerifiesIDTokenNonceAndAudience(t *testing.T) {
issuer = server.URL
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
AppEnv: "test",
Issuer: issuer, ClientID: "gateway-public-client", RedirectURI: "https://gateway.example.com/callback",
PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(),
})
@ -192,6 +198,7 @@ func TestOIDCPublicClientRefreshAndRevokeNeverSendSecret(t *testing.T) {
defer server.Close()
issuer = server.URL
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
AppEnv: "test",
Issuer: issuer, ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/callback",
PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(),
})
@ -231,6 +238,7 @@ func TestOIDCPublicClientMapsInvalidGrantWithoutLeakingProviderResponse(t *testi
issuer = server.URL
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
AppEnv: "test",
Issuer: issuer, ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/callback",
PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(),
})
@ -242,3 +250,43 @@ func TestOIDCPublicClientMapsInvalidGrantWithoutLeakingProviderResponse(t *testi
t.Fatalf("invalid_grant mapping was not stable and redacted: %v", err)
}
}
func TestOIDCPublicClientRejectsLoopbackHTTPDiscoveryEndpointsInProduction(t *testing.T) {
var issuer, insecureField string
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/.well-known/openid-configuration" {
http.NotFound(w, r)
return
}
metadata := map[string]any{
"issuer": issuer, "authorization_endpoint": issuer + "/authorize",
"token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks",
"revocation_endpoint": issuer + "/revoke", "end_session_endpoint": issuer + "/logout",
"id_token_signing_alg_values_supported": []string{"RS256", "ES256"},
}
metadata[insecureField] = "http://127.0.0.1:1/oidc-endpoint"
_ = json.NewEncoder(w).Encode(metadata)
}))
defer server.Close()
issuer = server.URL
for _, field := range []string{
"authorization_endpoint", "token_endpoint", "jwks_uri", "revocation_endpoint", "end_session_endpoint",
} {
t.Run(field, func(t *testing.T) {
insecureField = field
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
AppEnv: "production", Issuer: issuer, ClientID: "gateway-public",
RedirectURI: "https://gateway.example.com/callback", PostLogoutRedirectURI: "https://gateway.example.com/",
HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
if err := client.ValidateConfiguration(context.Background()); err == nil {
t.Fatalf("production accepted loopback HTTP %s", field)
}
})
}
}

View File

@ -2,10 +2,13 @@ package auth
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func TestAuthenticateResolvesOpaqueOIDCSessionCookie(t *testing.T) {
@ -49,6 +52,47 @@ func TestAuthenticateBearerTakesPrecedenceOverOIDCSessionCookie(t *testing.T) {
}
}
func TestAuthenticateRejectsMalformedExplicitCredentialInsteadOfFallingBackToCookie(t *testing.T) {
authenticator := New("local-jwt-secret", "", "")
var resolved bool
authenticator.OIDCSessionResolver = func(context.Context, string) (*User, error) {
resolved = true
return &User{ID: "cookie-manager", Source: "gateway", Roles: []string{"manager"}}, nil
}
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable", nil)
request.Header.Set("Authorization", "not-a-bearer-credential")
request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "valid-cookie-session"})
if _, err := authenticator.Authenticate(request); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("malformed explicit credential error=%v, want unauthorized", err)
}
if resolved {
t.Fatal("malformed Authorization header fell back to the OIDC session cookie")
}
}
func TestAuthenticateRejectsManagerJWTInQueryWithoutCookieFallback(t *testing.T) {
authenticator := New("local-jwt-secret", "", "")
managerToken, err := authenticator.SignJWT(&User{ID: "manager", Source: "gateway", Roles: []string{"manager"}}, time.Hour)
if err != nil {
t.Fatal(err)
}
var resolved bool
authenticator.OIDCSessionResolver = func(context.Context, string) (*User, error) {
resolved = true
return &User{ID: "cookie-manager", Source: "gateway", Roles: []string{"manager"}}, nil
}
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable?key="+managerToken, nil)
request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "valid-cookie-session"})
if _, err := authenticator.Authenticate(request); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("manager query JWT error=%v, want unauthorized", err)
}
if resolved {
t.Fatal("rejected query credential fell back to the OIDC session cookie")
}
}
func TestAuthenticateRejectsInvalidOIDCSessionCookie(t *testing.T) {
authenticator := New("local-jwt-secret", "", "")
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
@ -58,6 +102,69 @@ func TestAuthenticateRejectsInvalidOIDCSessionCookie(t *testing.T) {
}
}
func TestAuthenticatorReadsOIDCSessionResolverAndLegacyPolicyDynamically(t *testing.T) {
authenticator := New("local-jwt-secret", "", "")
currentUser := &User{ID: "first", Roles: []string{"manager"}}
authenticator.OIDCSessionResolverProvider = func(ctx context.Context, sessionID string) (*User, error) {
return currentUser, nil
}
authenticator.LegacyJWTEnabledProvider = func() bool { return false }
request := httptest.NewRequest(http.MethodGet, "/", nil)
request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "opaque-session-id"})
user, err := authenticator.Authenticate(request)
if err != nil || user.ID != "first" {
t.Fatalf("first runtime session resolution failed: user=%#v err=%v", user, err)
}
currentUser = &User{ID: "second", Roles: []string{"manager"}}
user, err = authenticator.Authenticate(request)
if err != nil || user.ID != "second" {
t.Fatalf("swapped runtime session resolution failed: user=%#v err=%v", user, err)
}
if authenticator.legacyJWTEnabled() {
t.Fatal("dynamic legacy JWT policy was ignored")
}
}
func TestAuthenticateKeepsOnlySignedBreakGlassManagerWhenLegacyJWTDisabled(t *testing.T) {
authenticator := New("local-jwt-secret", "", "")
authenticator.LegacyJWTEnabledProvider = func() bool { return false }
managerToken, err := authenticator.SignJWT(&User{ID: "manager", Source: "gateway", Roles: []string{"manager"}}, time.Hour)
if err != nil {
t.Fatal(err)
}
managerRequest := httptest.NewRequest(http.MethodGet, "/api/admin/system/identity/configuration", nil)
managerRequest.Header.Set("Authorization", "Bearer "+managerToken)
manager, err := authenticator.Authenticate(managerRequest)
if err != nil || manager.ID != "manager" {
t.Fatalf("signed break-glass manager was rejected: user=%#v err=%v", manager, err)
}
userToken, err := authenticator.SignJWT(&User{ID: "user", Source: "gateway", Roles: []string{"user"}}, time.Hour)
if err != nil {
t.Fatal(err)
}
userRequest := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
userRequest.Header.Set("Authorization", "Bearer "+userToken)
if _, err := authenticator.Authenticate(userRequest); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("ordinary local JWT error = %v, want unauthorized", err)
}
legacyManager := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": "legacy-manager", "source": "gateway", "role": []string{"manager"},
"iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(),
})
legacyManagerToken, err := legacyManager.SignedString([]byte(authenticator.JWTSecret))
if err != nil {
t.Fatal(err)
}
legacyRequest := httptest.NewRequest(http.MethodGet, "/api/admin/system/identity/configuration", nil)
legacyRequest.Header.Set("Authorization", "Bearer "+legacyManagerToken)
if _, err := authenticator.Authenticate(legacyRequest); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("legacy manager without token purpose error = %v, want unauthorized", err)
}
}
func TestPublicRouteIgnoresExpiredOptionalOIDCSession(t *testing.T) {
authenticator := New("local-jwt-secret", "", "")
authenticator.OIDCSessionResolver = func(context.Context, string) (*User, error) {

View File

@ -8,6 +8,7 @@ import (
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"math/big"
"net/http"
"net/http/httptest"
@ -42,12 +43,16 @@ func TestOIDCVerifierAcceptsRS256AndES256StableClaims(t *testing.T) {
defer server.Close()
issuer = server.URL
verifier, err := NewOIDCVerifier(OIDCConfig{
AppEnv: "test",
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1",
RolePrefix: "gateway.", RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
if err := verifier.ValidateConfiguration(context.Background()); err != nil {
t.Fatalf("ValidateConfiguration() error = %v", err)
}
for _, test := range []struct {
name string
kid string
@ -81,6 +86,7 @@ func TestOIDCVerifierRejectsMissingOrMismatchedSecurityClaims(t *testing.T) {
defer server.Close()
issuer = server.URL
verifier, _ := NewOIDCVerifier(OIDCConfig{
AppEnv: "test",
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
})
@ -135,10 +141,13 @@ func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing
defer server.Close()
issuer = server.URL
verifier, err := NewOIDCVerifier(OIDCConfig{
AppEnv: "test",
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
IntrospectionEnabled: true, IntrospectionClientID: "gateway-api",
IntrospectionClientSecret: "introspection-secret",
IntrospectionEnabled: true,
IntrospectionCredentialProvider: func(context.Context) (string, []byte, error) {
return "gateway-api", []byte("introspection-secret"), nil
},
})
if err != nil {
t.Fatal(err)
@ -153,6 +162,141 @@ func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing
}
}
func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *testing.T) {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
active, introspectionAvailable, introspectionCalls := true, true, 0
var issuer string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/.well-known/openid-configuration":
_ = json.NewEncoder(w).Encode(map[string]any{"issuer": issuer, "jwks_uri": issuer + "/jwks", "introspection_endpoint": issuer + "/introspect"})
case "/jwks":
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{ecJWK("ec-key", &key.PublicKey)}})
case "/introspect":
introspectionCalls++
if !introspectionAvailable {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"active": active})
}
}))
defer server.Close()
issuer = server.URL
evaluation := OIDCSecurityEventEvaluation{RequireIntrospection: true}
verifier, err := NewOIDCVerifier(OIDCConfig{
AppEnv: "test",
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
IntrospectionEnabled: true,
IntrospectionClientID: "gateway-introspection", IntrospectionClientSecret: "introspection-secret",
SecurityEventEvaluator: func(_ context.Context, identity OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error) {
if identity.Subject != "platform-subject" {
t.Fatal("security event evaluator received incomplete identity")
}
return evaluation, nil
},
})
if err != nil {
t.Fatal(err)
}
raw := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, nil)
if _, err := verifier.Verify(context.Background(), raw); err != nil || introspectionCalls != 1 {
t.Fatalf("fallback token error=%v calls=%d", err, introspectionCalls)
}
evaluation = OIDCSecurityEventEvaluation{Enabled: true}
if _, err := verifier.Verify(context.Background(), raw); err != nil || introspectionCalls != 1 {
t.Fatalf("healthy push token error=%v calls=%d", err, introspectionCalls)
}
evaluation = OIDCSecurityEventEvaluation{Revoked: true}
if _, err := verifier.Verify(context.Background(), raw); err == nil || introspectionCalls != 1 {
t.Fatalf("revoked token error=%v calls=%d", err, introspectionCalls)
}
evaluation = OIDCSecurityEventEvaluation{RequireIntrospection: true}
introspectionAvailable = false
_, err = verifier.Verify(context.Background(), raw)
var requestError *RequestAuthError
if !errors.As(err, &requestError) || requestError.Status != http.StatusServiceUnavailable {
t.Fatalf("introspection outage error=%T %v", err, err)
}
withoutIssuedAt := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, func(claims jwt.MapClaims) { delete(claims, "iat") })
evaluation = OIDCSecurityEventEvaluation{}
introspectionAvailable = true
if _, err := verifier.Verify(context.Background(), withoutIssuedAt); err != nil {
t.Fatalf("unconfigured security events unexpectedly required iat: %v", err)
}
evaluation = OIDCSecurityEventEvaluation{Enabled: true}
if _, err := verifier.Verify(context.Background(), withoutIssuedAt); err == nil {
t.Fatal("configured security events accepted a token without iat")
}
}
func TestOIDCVerifierRejectsLoopbackHTTPDiscoveryEndpointsInProduction(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
var issuer, jwksURI, introspectionEndpoint string
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch request.URL.Path {
case "/.well-known/openid-configuration":
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": issuer, "jwks_uri": jwksURI, "introspection_endpoint": introspectionEndpoint,
})
case "/jwks":
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{rsaJWK("rsa-key", &key.PublicKey)}})
default:
http.NotFound(w, request)
}
}))
defer server.Close()
issuer = server.URL
for _, test := range []struct {
name string
jwks string
introspection string
}{
{name: "JWKS", jwks: "http://127.0.0.1:1/jwks"},
{name: "introspection", jwks: issuer + "/jwks", introspection: "http://localhost:1/introspect"},
} {
t.Run(test.name, func(t *testing.T) {
jwksURI, introspectionEndpoint = test.jwks, test.introspection
config := OIDCConfig{
AppEnv: "production", Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
HTTPClient: server.Client(),
}
if test.introspection != "" {
config.IntrospectionEnabled = true
config.IntrospectionCredentialProvider = func(context.Context) (string, []byte, error) {
return "gateway-machine", []byte("machine-secret-long-enough"), nil
}
}
verifier, createErr := NewOIDCVerifier(config)
if createErr != nil {
t.Fatal(createErr)
}
if validateErr := verifier.ValidateConfiguration(context.Background()); validateErr == nil {
t.Fatalf("production accepted loopback HTTP %s endpoint", test.name)
}
})
}
}
func TestOIDCURLPolicyAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) {
for _, appEnv := range []string{"", "production", "staging"} {
if err := validatePublicURL("http://127.0.0.2:18003/issuer/easyai", appEnv); err == nil {
t.Fatalf("%s accepted loopback HTTP OIDC URL", appEnv)
}
}
for _, appEnv := range []string{"local", "development", "dev", "test"} {
if err := validatePublicURL("http://127.0.0.2:18003/issuer/easyai", appEnv); err != nil {
t.Fatalf("%s rejected loopback HTTP OIDC URL: %v", appEnv, err)
}
}
}
func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod, key any, mutate func(jwt.MapClaims)) string {
t.Helper()
now := time.Now()

View File

@ -1,7 +1,6 @@
package config
import (
"encoding/base64"
"errors"
"log/slog"
"net/url"
@ -16,50 +15,38 @@ const (
)
type Config struct {
AppEnv string
HTTPAddr string
DatabaseURL string
IdentityMode string
JWTSecret string
ServerMainBaseURL string
ServerMainInternalToken string
ServerMainInternalKey string
ServerMainInternalSecret string
OIDCEnabled bool
OIDCIssuer string
OIDCAudience string
OIDCTenantID string
OIDCRolePrefix string
OIDCRequiredScopes []string
OIDCJWKSCacheTTLSeconds int
OIDCAcceptLegacyHS256 bool
OIDCIntrospectionEnabled bool
OIDCIntrospectionClientID string
OIDCIntrospectionClientSecret string
OIDCJITProvisioningEnabled bool
OIDCGatewayTenantKey string
OIDCBrowserSessionEnabled bool
OIDCSessionCookieSecure bool
OIDCClientID string
OIDCRedirectURI string
OIDCPostLogoutRedirectURI string
OIDCSessionEncryptionKey string
OIDCSessionIdleTTLSeconds int
OIDCSessionAbsoluteTTLSeconds int
OIDCSessionRefreshBeforeSeconds int
PublicBaseURL string
WebBaseURL string
LocalGeneratedStorageDir string
LocalUploadedStorageDir string
LocalTempAssetTTLHours int
TaskProgressCallbackEnabled bool
TaskProgressCallbackURL string
TaskProgressCallbackTimeoutMS string
TaskProgressCallbackMaxAttempts string
CORSAllowedOrigin string
GlobalHTTPProxy string
GlobalHTTPProxySource string
LogLevel slog.Level
AppEnv string
HTTPAddr string
DatabaseURL string
IdentityMode string
JWTSecret string
ServerMainBaseURL string
ServerMainInternalToken string
ServerMainInternalKey string
ServerMainInternalSecret string
IdentitySecretStore string
IdentitySecretDir string
IdentityKubernetesNamespace string
IdentityKubernetesSecretName string
IdentityKubernetesAPIServer string
IdentityKubernetesTokenFile string
IdentityKubernetesCAFile string
IdentitySecurityEventHeartbeatIntervalSeconds int
IdentitySecurityEventStaleAfterSeconds int
IdentitySecurityEventClockSkewSeconds int
PublicBaseURL string
WebBaseURL string
LocalGeneratedStorageDir string
LocalUploadedStorageDir string
LocalTempAssetTTLHours int
TaskProgressCallbackEnabled bool
TaskProgressCallbackURL string
TaskProgressCallbackTimeoutMS string
TaskProgressCallbackMaxAttempts string
CORSAllowedOrigin string
GlobalHTTPProxy string
GlobalHTTPProxySource string
LogLevel slog.Level
}
func Load() Config {
@ -75,39 +62,25 @@ func Load() Config {
env("SERVER_MAIN_BASE_URL", "http://localhost:3000"),
"/",
),
ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""),
ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"),
ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")),
OIDCEnabled: env("OIDC_ENABLED", "false") == "true",
OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", ""), "/"),
OIDCAudience: env("OIDC_AUDIENCE", ""),
OIDCTenantID: env("OIDC_TENANT_ID", ""),
OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."),
OIDCRequiredScopes: splitCSV(env("OIDC_REQUIRED_SCOPES", "gateway.access")),
OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300),
OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true",
OIDCIntrospectionEnabled: env("OIDC_INTROSPECTION_ENABLED", "false") == "true",
OIDCIntrospectionClientID: env("OIDC_INTROSPECTION_CLIENT_ID", ""),
OIDCIntrospectionClientSecret: env("OIDC_INTROSPECTION_CLIENT_SECRET", ""),
OIDCJITProvisioningEnabled: env("OIDC_JIT_PROVISIONING_ENABLED", "false") == "true",
OIDCGatewayTenantKey: env("OIDC_GATEWAY_TENANT_KEY", ""),
OIDCBrowserSessionEnabled: env("OIDC_BROWSER_SESSION_ENABLED", "true") == "true",
OIDCSessionCookieSecure: env("OIDC_SESSION_COOKIE_SECURE",
strconv.FormatBool(!isLocalEnvironment(appEnv)),
) == "true",
OIDCClientID: env("OIDC_CLIENT_ID", ""),
OIDCRedirectURI: env("OIDC_REDIRECT_URI", ""),
OIDCPostLogoutRedirectURI: env("OIDC_POST_LOGOUT_REDIRECT_URI", ""),
OIDCSessionEncryptionKey: env("OIDC_SESSION_ENCRYPTION_KEY", ""),
OIDCSessionIdleTTLSeconds: envInt("OIDC_SESSION_IDLE_TTL_SECONDS", 1800),
OIDCSessionAbsoluteTTLSeconds: envInt("OIDC_SESSION_ABSOLUTE_TTL_SECONDS", 28800),
OIDCSessionRefreshBeforeSeconds: envInt("OIDC_SESSION_REFRESH_BEFORE_SECONDS", 60),
PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"),
WebBaseURL: strings.TrimRight(env("AI_GATEWAY_WEB_BASE_URL", env("GATEWAY_WEB_BASE_URL", env("PUBLIC_WEB_BASE_URL", ""))), "/"),
LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))),
LocalUploadedStorageDir: env("AI_GATEWAY_UPLOADED_STORAGE_DIR", env("LOCAL_UPLOADED_STORAGE_DIR", DefaultLocalUploadedStorageDir)),
LocalTempAssetTTLHours: envInt("AI_GATEWAY_LOCAL_TEMP_ASSET_TTL_HOURS", 24),
TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true",
ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""),
ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"),
ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")),
IdentitySecretStore: env("IDENTITY_SECRET_STORE", "file"),
IdentitySecretDir: env("IDENTITY_SECRET_DIR", ".local-secrets/identity"),
IdentityKubernetesNamespace: env("IDENTITY_KUBERNETES_NAMESPACE", env("POD_NAMESPACE", "")),
IdentityKubernetesSecretName: env("IDENTITY_KUBERNETES_SECRET_NAME", "easyai-gateway-identity"),
IdentityKubernetesAPIServer: env("IDENTITY_KUBERNETES_API_SERVER", "https://kubernetes.default.svc"),
IdentityKubernetesTokenFile: env("IDENTITY_KUBERNETES_TOKEN_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/token"),
IdentityKubernetesCAFile: env("IDENTITY_KUBERNETES_CA_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"),
IdentitySecurityEventHeartbeatIntervalSeconds: envInt("IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS", 60),
IdentitySecurityEventStaleAfterSeconds: envInt("IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS", 180),
IdentitySecurityEventClockSkewSeconds: envInt("IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS", 60),
PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"),
WebBaseURL: strings.TrimRight(env("AI_GATEWAY_WEB_BASE_URL", env("GATEWAY_WEB_BASE_URL", env("PUBLIC_WEB_BASE_URL", ""))), "/"),
LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))),
LocalUploadedStorageDir: env("AI_GATEWAY_UPLOADED_STORAGE_DIR", env("LOCAL_UPLOADED_STORAGE_DIR", DefaultLocalUploadedStorageDir)),
LocalTempAssetTTLHours: envInt("AI_GATEWAY_LOCAL_TEMP_ASSET_TTL_HOURS", 24),
TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true",
TaskProgressCallbackURL: env("TASK_PROGRESS_CALLBACK_URL",
strings.TrimRight(env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/")+"/internal/platform/task-progress-callbacks",
),
@ -121,76 +94,29 @@ func Load() Config {
}
func (c Config) Validate() error {
if c.OIDCJITProvisioningEnabled && strings.TrimSpace(c.OIDCGatewayTenantKey) == "" {
return errors.New("OIDC_GATEWAY_TENANT_KEY is required when OIDC_JIT_PROVISIONING_ENABLED=true")
switch strings.ToLower(strings.TrimSpace(c.IdentitySecretStore)) {
case "":
case "file":
if strings.TrimSpace(c.IdentitySecretDir) == "" {
return errors.New("IDENTITY_SECRET_DIR is required for the file SecretStore")
}
case "kubernetes":
if strings.TrimSpace(c.IdentityKubernetesNamespace) == "" || strings.TrimSpace(c.IdentityKubernetesSecretName) == "" {
return errors.New("Kubernetes identity SecretStore requires namespace and Secret name")
}
default:
return errors.New("IDENTITY_SECRET_STORE must be file or kubernetes")
}
if c.OIDCEnabled && c.OIDCBrowserSessionEnabled {
for _, scope := range c.OIDCRequiredScopes {
if strings.EqualFold(strings.TrimSpace(scope), "offline_access") {
return errors.New("OIDC_REQUIRED_SCOPES cannot contain offline_access for browser sessions")
}
}
if strings.TrimSpace(c.OIDCClientID) == "" {
return errors.New("OIDC_CLIENT_ID is required when OIDC browser sessions are enabled")
}
if !validPublicRedirectURL(c.OIDCRedirectURI) {
return errors.New("OIDC_REDIRECT_URI must be an exact HTTPS URL (localhost HTTP is allowed for development)")
}
if !validPublicRedirectURL(c.OIDCPostLogoutRedirectURI) {
return errors.New("OIDC_POST_LOGOUT_REDIRECT_URI must be an exact HTTPS URL (localhost HTTP is allowed for development)")
}
if _, err := c.OIDCSessionEncryptionKeyBytes(); err != nil {
return err
}
if c.OIDCSessionIdleTTLSeconds <= 0 || c.OIDCSessionAbsoluteTTLSeconds <= c.OIDCSessionIdleTTLSeconds {
return errors.New("OIDC session idle TTL must be positive and shorter than the absolute TTL")
}
if c.OIDCSessionRefreshBeforeSeconds <= 0 || c.OIDCSessionRefreshBeforeSeconds >= c.OIDCSessionIdleTTLSeconds {
return errors.New("OIDC_SESSION_REFRESH_BEFORE_SECONDS must be positive and shorter than the idle TTL")
}
if !isLocalEnvironment(c.AppEnv) && !c.OIDCSessionCookieSecure {
return errors.New("OIDC_SESSION_COOKIE_SECURE must be true outside local development and tests")
}
for _, origin := range strings.Split(c.CORSAllowedOrigin, ",") {
if strings.TrimSpace(origin) == "*" {
return errors.New("CORS_ALLOWED_ORIGIN cannot contain * when OIDC browser sessions are enabled")
}
if c.IdentitySecurityEventHeartbeatIntervalSeconds != 0 || c.IdentitySecurityEventStaleAfterSeconds != 0 || c.IdentitySecurityEventClockSkewSeconds != 0 {
if c.IdentitySecurityEventHeartbeatIntervalSeconds <= 0 ||
c.IdentitySecurityEventStaleAfterSeconds < 2*c.IdentitySecurityEventHeartbeatIntervalSeconds ||
c.IdentitySecurityEventClockSkewSeconds < 0 || c.IdentitySecurityEventClockSkewSeconds > 300 {
return errors.New("identity security event heartbeat, stale threshold, or clock skew is invalid")
}
}
return nil
}
func (c Config) OIDCSessionEncryptionKeyBytes() ([]byte, error) {
raw := strings.TrimSpace(c.OIDCSessionEncryptionKey)
for _, encoding := range []*base64.Encoding{base64.RawStdEncoding, base64.StdEncoding, base64.RawURLEncoding, base64.URLEncoding} {
decoded, err := encoding.DecodeString(raw)
if err == nil && len(decoded) == 32 {
return decoded, nil
}
}
return nil, errors.New("OIDC_SESSION_ENCRYPTION_KEY must be a base64-encoded 32-byte random key")
}
func validPublicRedirectURL(raw string) bool {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return false
}
if parsed.Scheme == "https" {
return true
}
return parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")
}
func isLocalEnvironment(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "development", "dev", "local", "test":
return true
default:
return false
}
}
type GlobalHTTPProxyStatus struct {
HTTPProxy string
Source string
@ -245,17 +171,6 @@ func normalizePostgresURL(raw string) string {
return parsed.String()
}
func splitCSV(value string) []string {
items := strings.Split(value, ",")
result := make([]string, 0, len(items))
for _, item := range items {
if trimmed := strings.TrimSpace(item); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
func withDatabase(raw string, databaseName string) string {
parsed, err := url.Parse(raw)
if err != nil || databaseName == "" {

View File

@ -1,138 +1,59 @@
package config
import (
"bytes"
"encoding/base64"
"fmt"
"strings"
"testing"
)
func TestLoadOIDCJITProvisioningDefaultsToDisabled(t *testing.T) {
t.Setenv("OIDC_JIT_PROVISIONING_ENABLED", "")
t.Setenv("OIDC_GATEWAY_TENANT_KEY", "")
func TestLoadIdentitySecretStoreUsesNewEnvironmentNamesAndIgnoresLegacyBusinessValues(t *testing.T) {
t.Setenv("IDENTITY_SECRET_STORE", "file")
t.Setenv("IDENTITY_SECRET_DIR", ".test-secrets/identity")
t.Setenv("OIDC_ISSUER", "https://legacy-sensitive.example/issuer")
t.Setenv("OIDC_INTROSPECTION_CLIENT_SECRET", "legacy-sensitive-secret")
t.Setenv("OIDC_SESSION_ENCRYPTION_KEY", "legacy-sensitive-session-key")
cfg := Load()
if cfg.OIDCJITProvisioningEnabled {
t.Fatal("OIDC JIT provisioning must be disabled by default")
if cfg.IdentitySecretStore != "file" || cfg.IdentitySecretDir != ".test-secrets/identity" {
t.Fatalf("identity SecretStore = %q %q", cfg.IdentitySecretStore, cfg.IdentitySecretDir)
}
if cfg.OIDCGatewayTenantKey != "" {
t.Fatalf("unexpected gateway tenant key: %q", cfg.OIDCGatewayTenantKey)
printed := fmt.Sprintf("%+v", cfg)
for _, legacyValue := range []string{"legacy-sensitive.example", "legacy-sensitive-secret", "legacy-sensitive-session-key"} {
if strings.Contains(printed, legacyValue) {
t.Fatalf("legacy identity business setting was loaded into Config: %q", legacyValue)
}
}
}
func TestValidateRequiresGatewayTenantKeyWhenOIDCJITIsEnabled(t *testing.T) {
cfg := Config{
OIDCEnabled: true,
OIDCJITProvisioningEnabled: true,
func TestValidateIdentityFileSecretStoreRequiresDirectory(t *testing.T) {
cfg := Config{IdentitySecretStore: "file"}
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "IDENTITY_SECRET_DIR") {
t.Fatalf("Validate() error = %v, want missing identity secret directory", err)
}
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "OIDC_GATEWAY_TENANT_KEY") {
t.Fatalf("Validate() error = %v, want missing OIDC_GATEWAY_TENANT_KEY", err)
}
cfg.OIDCGatewayTenantKey = "default"
cfg.IdentitySecretDir = ".test-secrets/identity"
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() with tenant key: %v", err)
t.Fatalf("valid file SecretStore was rejected: %v", err)
}
}
func TestLoadOIDCBrowserSessionUsesSafeEnvironmentDefaults(t *testing.T) {
t.Setenv("APP_ENV", "development")
t.Setenv("OIDC_BROWSER_SESSION_ENABLED", "")
t.Setenv("OIDC_SESSION_COOKIE_SECURE", "")
cfg := Load()
if !cfg.OIDCBrowserSessionEnabled {
t.Fatal("OIDC browser session should be enabled by default")
func TestValidateIdentityKubernetesSecretStore(t *testing.T) {
cfg := Config{IdentitySecretStore: "kubernetes", IdentityKubernetesSecretName: "easyai-gateway-identity"}
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "namespace") {
t.Fatalf("Validate() error = %v, want missing namespace", err)
}
if cfg.OIDCSessionCookieSecure {
t.Fatal("development cookie should allow localhost HTTP by default")
}
t.Setenv("APP_ENV", "production")
cfg = Load()
if !cfg.OIDCSessionCookieSecure {
t.Fatal("production OIDC session cookie must default to Secure")
}
t.Setenv("APP_ENV", "staging")
cfg = Load()
if !cfg.OIDCSessionCookieSecure {
t.Fatal("staging OIDC session cookie must default to Secure")
}
}
func TestValidateRejectsInsecureNonLocalOIDCBrowserSession(t *testing.T) {
cfg := Config{
AppEnv: "staging",
OIDCEnabled: true,
OIDCBrowserSessionEnabled: true,
OIDCSessionCookieSecure: false,
CORSAllowedOrigin: "https://gateway.example.com",
OIDCClientID: "gateway-public",
OIDCRedirectURI: "https://gateway.example.com/gateway-api/api/v1/auth/oidc/callback",
OIDCPostLogoutRedirectURI: "https://gateway.example.com/",
OIDCSessionEncryptionKey: base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{1}, 32)),
OIDCSessionIdleTTLSeconds: 1800,
OIDCSessionAbsoluteTTLSeconds: 28800,
OIDCSessionRefreshBeforeSeconds: 60,
}
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "OIDC_SESSION_COOKIE_SECURE") {
t.Fatalf("Validate() error = %v, want insecure non-local cookie rejection", err)
}
cfg.OIDCSessionCookieSecure = true
cfg.CORSAllowedOrigin = "*"
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "CORS_ALLOWED_ORIGIN") {
t.Fatalf("Validate() error = %v, want wildcard credentialed CORS rejection", err)
}
}
func TestValidateRequiresCompletePublicClientSessionConfiguration(t *testing.T) {
cfg := Config{
AppEnv: "test", OIDCEnabled: true, OIDCBrowserSessionEnabled: true,
OIDCSessionCookieSecure: false, CORSAllowedOrigin: "http://localhost:5178",
}
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "OIDC_CLIENT_ID") {
t.Fatalf("Validate() error = %v, want incomplete public client rejection", err)
}
cfg.OIDCClientID = "gateway-public"
cfg.OIDCRedirectURI = "http://localhost:8088/api/v1/auth/oidc/callback"
cfg.OIDCPostLogoutRedirectURI = "http://localhost:5178/"
cfg.OIDCSessionEncryptionKey = base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{2}, 32))
cfg.OIDCSessionIdleTTLSeconds = 1800
cfg.OIDCSessionAbsoluteTTLSeconds = 28800
cfg.OIDCSessionRefreshBeforeSeconds = 60
cfg.IdentityKubernetesNamespace = "easyai"
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() valid public session config: %v", err)
}
key, err := cfg.OIDCSessionEncryptionKeyBytes()
if err != nil || len(key) != 32 {
t.Fatalf("decoded encryption key len=%d err=%v", len(key), err)
t.Fatalf("valid Kubernetes SecretStore was rejected: %v", err)
}
}
func TestValidateRejectsPlaintextOrWrongLengthSessionKey(t *testing.T) {
func TestValidateIdentitySecurityEventTiming(t *testing.T) {
cfg := Config{
AppEnv: "test", OIDCEnabled: true, OIDCBrowserSessionEnabled: true,
OIDCClientID: "gateway-public", OIDCRedirectURI: "http://localhost:8088/callback",
OIDCPostLogoutRedirectURI: "http://localhost:5178/", OIDCSessionEncryptionKey: strings.Repeat("x", 32),
OIDCSessionIdleTTLSeconds: 1800, OIDCSessionAbsoluteTTLSeconds: 28800, OIDCSessionRefreshBeforeSeconds: 60,
CORSAllowedOrigin: "http://localhost:5178",
IdentitySecurityEventHeartbeatIntervalSeconds: 60,
IdentitySecurityEventStaleAfterSeconds: 60,
IdentitySecurityEventClockSkewSeconds: 60,
}
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "OIDC_SESSION_ENCRYPTION_KEY") {
t.Fatalf("Validate() error = %v, want encoded AES-256 key rejection", err)
}
}
func TestValidateRejectsOfflineAccessForBrowserSession(t *testing.T) {
cfg := Config{
AppEnv: "test", OIDCEnabled: true, OIDCBrowserSessionEnabled: true,
OIDCRequiredScopes: []string{"gateway.access", "offline_access"},
}
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "offline_access") {
t.Fatalf("Validate() error = %v, want offline_access rejection", err)
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "heartbeat") {
t.Fatalf("Validate() error = %v, want invalid stale threshold", err)
}
}

View File

@ -73,6 +73,12 @@ func TestCoreLocalFlow(t *testing.T) {
if registerResponse.AccessToken == "" {
t.Fatal("register did not return access token")
}
ordinaryUsername := "smoke_user_" + suffixText
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
"username": ordinaryUsername,
"email": ordinaryUsername + "@example.com",
"password": password,
}, http.StatusCreated, &struct{}{})
var duplicateResponse map[string]any
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
@ -129,6 +135,25 @@ func TestCoreLocalFlow(t *testing.T) {
if _, err := testPool.Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil {
t.Fatalf("promote smoke user: %v", err)
}
serverMainCtx, cancelServerMain := context.WithCancel(ctx)
serverMain := httptest.NewServer(NewServerWithContext(serverMainCtx, config.Config{
AppEnv: "test", HTTPAddr: ":0", DatabaseURL: databaseURL, IdentityMode: "server-main",
JWTSecret: "test-secret", CORSAllowedOrigin: "*",
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
var breakGlassLogin struct {
AccessToken string `json:"accessToken"`
}
doJSON(t, serverMain.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"account": username, "password": password,
}, http.StatusOK, &breakGlassLogin)
if breakGlassLogin.AccessToken == "" {
t.Fatal("server-main break-glass manager login did not return access token")
}
doJSON(t, serverMain.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"account": ordinaryUsername, "password": password,
}, http.StatusForbidden, &map[string]any{})
serverMain.Close()
cancelServerMain()
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"account": username,
"password": password,
@ -1697,6 +1722,9 @@ func TestOriginAllowedSupportsCommaSeparatedOrigins(t *testing.T) {
if originAllowed("http://127.0.0.1:5179", allowed) {
t.Fatal("unexpected origin should not be allowed")
}
if originAllowed("https://evil.example.com", "*") {
t.Fatal("credentialed wildcard CORS origin should not be allowed")
}
}
func assertRuntimeRecoveryReleasesPendingRateReservations(t *testing.T, ctx context.Context, db *store.Store) {

View File

@ -79,7 +79,7 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
// @Failure 500 {object} ErrorEnvelope
// @Router /api/v1/auth/register [post]
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
if !s.localIdentityEnabled() {
if !s.localIdentityEnabled() || !s.ordinaryLocalJWTEnabled() {
writeError(w, http.StatusForbidden, "local registration is disabled")
return
}
@ -111,7 +111,7 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
// login godoc
// @Summary 本地登录
// @Description 使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。
// @Description 使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。非本地身份模式只允许本地应急 Manager 登录。
// @Tags auth
// @Accept json
// @Produce json
@ -123,10 +123,6 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
// @Failure 500 {object} ErrorEnvelope
// @Router /api/v1/auth/login [post]
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
if !s.localIdentityEnabled() {
writeError(w, http.StatusForbidden, "local login is disabled")
return
}
var input store.LocalLoginInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
@ -142,6 +138,10 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "login failed")
return
}
if !s.localLoginAllowed(user) {
writeError(w, http.StatusForbidden, "local login is disabled except for break-glass managers")
return
}
s.writeAuthResponse(w, http.StatusOK, user)
}
@ -150,6 +150,19 @@ func (s *Server) localIdentityEnabled() bool {
return mode == "" || mode == "standalone" || mode == "hybrid"
}
func (s *Server) localLoginAllowed(user store.GatewayUser) bool {
for _, role := range user.Roles {
if role == "manager" || role == "admin" {
return true
}
}
return s.localIdentityEnabled() && s.ordinaryLocalJWTEnabled()
}
func (s *Server) ordinaryLocalJWTEnabled() bool {
return s.identityRuntime == nil || s.identityRuntime.LegacyJWTEnabled()
}
func (s *Server) writeAuthResponse(w http.ResponseWriter, status int, user store.GatewayUser) {
authUser := authUserFromGatewayUser(user)
const ttl = 24 * time.Hour

View File

@ -0,0 +1,820 @@
package httpapi
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type identityConfigurationView struct {
Active *identity.Revision `json:"active"`
Draft *identity.Revision `json:"draft"`
Previous *identity.Revision `json:"previous"`
Pairing *identity.PairingExchange `json:"pairing,omitempty"`
Runtime identityRuntimeStatus `json:"runtime"`
}
type identityRuntimeStatus struct {
Enabled bool `json:"enabled"`
RevisionID string `json:"revisionId,omitempty"`
Login string `json:"login"`
JIT string `json:"jit"`
TokenIntrospection string `json:"tokenIntrospection"`
SessionRevocation string `json:"sessionRevocation"`
LastTraceID string `json:"lastTraceId,omitempty"`
LastAuditID string `json:"lastAuditId,omitempty"`
LastErrorCategory string `json:"lastErrorCategory,omitempty"`
}
type publicIdentityConfiguration struct {
Enabled bool `json:"enabled"`
OIDCLogin bool `json:"oidcLogin"`
LoginURL string `json:"loginUrl,omitempty"`
LogoutURL string `json:"logoutUrl,omitempty"`
Status string `json:"status"`
}
type identityPolicyPatch struct {
LocalTenantKey *string `json:"localTenantKey"`
RolePrefix *string `json:"rolePrefix"`
JITEnabled *bool `json:"jitEnabled"`
LegacyJWTEnabled *bool `json:"legacyJwtEnabled"`
SessionIdleSeconds *int `json:"sessionIdleSeconds"`
SessionAbsoluteSeconds *int `json:"sessionAbsoluteSeconds"`
SessionRefreshSeconds *int `json:"sessionRefreshSeconds"`
}
type storedIdentityResponse struct {
Status int `json:"status"`
Body json.RawMessage `json:"body"`
ETag string `json:"etag,omitempty"`
AuditID string `json:"auditId,omitempty"`
}
type identityWriteOperation struct {
operation, key, requestHash string
release func()
once sync.Once
}
type identityPairingWorker struct {
cancel context.CancelFunc
done chan struct{}
}
func (operation *identityWriteOperation) close() {
if operation != nil {
operation.once.Do(operation.release)
}
}
// getPublicIdentityConfiguration godoc
// @Summary 获取公开统一认证状态
// @Description 返回 Web 运行时需要的启用状态和非敏感登录、退出入口,不返回 Client、Secret 或内部标识。
// @Tags identity
// @Produce json
// @Success 200 {object} publicIdentityConfiguration
// @Router /api/v1/public/identity [get]
func (s *Server) getPublicIdentityConfiguration(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Cache-Control", "no-store")
runtime := s.currentIdentityRuntime()
if runtime == nil || runtime.Revision.State != identity.RevisionActive {
writeJSON(w, http.StatusOK, publicIdentityConfiguration{Status: "disabled"})
return
}
view := publicIdentityConfiguration{Enabled: true, Status: "active"}
if oidcRuntimeReady(runtime) {
view.OIDCLogin = true
view.LoginURL = "/api/v1/auth/oidc/login"
view.LogoutURL = "/api/v1/auth/oidc/logout"
}
writeJSON(w, http.StatusOK, view)
}
// getIdentityConfiguration godoc
// @Summary 获取统一认证配置
// @Description 返回 Active、Draft、Previous Revision、配对进度和脱敏运行时健康状态。
// @Tags identity
// @Produce json
// @Security BearerAuth
// @Success 200 {object} identityConfigurationView
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope
// @Router /api/admin/system/identity/configuration [get]
func (s *Server) getIdentityConfiguration(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
view := identityConfigurationView{Runtime: s.identityRuntimeStatus(r)}
if revision, err := s.store.ActiveIdentityConfigurationRevision(r.Context()); err == nil {
view.Active = &revision
} else if !errors.Is(err, identity.ErrRevisionNotFound) {
writeError(w, http.StatusServiceUnavailable, "统一认证配置暂时不可用", "IDENTITY_CONFIGURATION_UNAVAILABLE")
return
}
if revision, err := s.store.LatestInactiveIdentityConfigurationRevision(r.Context()); err == nil {
view.Draft = &revision
if pairing, pairingErr := s.store.IdentityPairingExchangeForRevision(r.Context(), revision.ID); pairingErr == nil {
view.Pairing = &pairing
}
} else if !errors.Is(err, identity.ErrRevisionNotFound) {
writeError(w, http.StatusServiceUnavailable, "统一认证草稿暂时不可用", "IDENTITY_CONFIGURATION_UNAVAILABLE")
return
}
if revision, err := s.store.LatestSupersededIdentityConfigurationRevision(r.Context()); err == nil {
view.Previous = &revision
} else if !errors.Is(err, identity.ErrRevisionNotFound) {
writeError(w, http.StatusServiceUnavailable, "统一认证历史版本暂时不可用", "IDENTITY_CONFIGURATION_UNAVAILABLE")
return
}
writeJSON(w, http.StatusOK, view)
}
// startIdentityPairing godoc
// @Summary 启动统一认证配对
// @Description 使用一次性接入码创建 Draft 和异步 Exchange。接入码仅允许出现在请求 Body。
// @Tags identity
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "幂等键"
// @Param If-Match header string true "首次配对固定为 W/\"0\""
// @Param body body identity.PairingInput true "配对参数"
// @Success 202 {object} identity.PairingExchange
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Failure 428 {object} ErrorEnvelope
// @Router /api/admin/system/identity/pairings [post]
func (s *Server) startIdentityPairing(w http.ResponseWriter, r *http.Request) {
var input identity.PairingInput
if !decodeIdentityRequest(w, r, &input) {
return
}
operation, ok := s.beginIdentityWrite(w, r, "pairing.start", 0, input)
if !ok {
return
}
defer operation.close()
traceID := ensureIdentityTraceID(w, r)
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "pairing.start", "pending", traceID)
if !ok {
return
}
pairing, err := s.identityPairing.Start(r.Context(), input, traceID)
if err != nil {
s.writeIdentityError(w, r, "pairing.start", "", traceID, err)
return
}
pairing.AuthCenterAuditID = ""
s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID)
s.startIdentityPairingWorker(pairing.ID)
}
// getIdentityPairing godoc
// @Summary 查询统一认证配对状态
// @Tags identity
// @Produce json
// @Security BearerAuth
// @Param pairingID path string true "配对 ID"
// @Success 200 {object} identity.PairingExchange
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /api/admin/system/identity/pairings/{pairingID} [get]
func (s *Server) getIdentityPairing(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
pairing, err := s.store.IdentityPairingExchange(r.Context(), r.PathValue("pairingID"))
if err != nil {
s.writeIdentityError(w, r, "pairing.get", r.PathValue("pairingID"), ensureIdentityTraceID(w, r), err)
return
}
w.Header().Set("ETag", identityETag(pairing.Version))
writeJSON(w, http.StatusOK, pairing)
}
// cancelIdentityPairing godoc
// @Summary 放弃本地统一认证配对
// @Description 原子封存未激活 Revision并异步清理由该 Revision 拥有的临时 Secret 与 SSF 连接。不会撤销已经完成的远端 Exchange下一次凭据交付会轮换机器凭据。
// @Tags identity
// @Produce json
// @Security BearerAuth
// @Param pairingID path string true "配对 ID"
// @Param Idempotency-Key header string true "幂等键"
// @Param If-Match header string true "当前 Pairing ETag"
// @Success 202 {object} identity.PairingExchange
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Failure 412 {object} ErrorEnvelope
// @Failure 428 {object} ErrorEnvelope
// @Router /api/admin/system/identity/pairings/{pairingID}/cancel [post]
func (s *Server) cancelIdentityPairing(w http.ResponseWriter, r *http.Request) {
expectedVersion, ok := requiredIdentityVersion(w, r)
if !ok {
return
}
pairingID := r.PathValue("pairingID")
operation, ok := s.beginIdentityWriteWithVersion(w, r, "pairing.cancel", expectedVersion, struct {
PairingID string `json:"pairingId"`
}{PairingID: pairingID})
if !ok {
return
}
defer operation.close()
traceID := ensureIdentityTraceID(w, r)
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "pairing.cancel", pairingID, traceID)
if !ok {
return
}
pairing, err := s.identityPairing.Cancel(r.Context(), pairingID, expectedVersion, traceID, auditID)
if err != nil {
s.writeIdentityError(w, r, "pairing.cancel", pairingID, traceID, err)
return
}
done := s.stopIdentityPairingWorker(pairing.ID)
s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID)
s.startIdentityPairingCleanupWorker(pairing.ID, done)
}
// retireIdentityPairingSecurityEventConflict godoc
// @Summary 安全退役阻塞配对的旧 SSF 连接
// @Description 仅当指定 Pairing 仍因 owner 冲突停在 credentials_saved 时,退役不属于该 Revision 的旧连接;陈旧请求不会退役当前 Pairing 已创建的新连接。
// @Tags identity
// @Produce json
// @Security BearerAuth
// @Param pairingID path string true "配对 ID"
// @Param Idempotency-Key header string true "幂等键"
// @Param If-Match header string true "当前 Pairing ETag"
// @Success 202 {object} identity.PairingExchange
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Failure 412 {object} ErrorEnvelope
// @Failure 428 {object} ErrorEnvelope
// @Router /api/admin/system/identity/pairings/{pairingID}/retire-conflicting-security-event [post]
func (s *Server) retireIdentityPairingSecurityEventConflict(w http.ResponseWriter, r *http.Request) {
expectedVersion, ok := requiredIdentityVersion(w, r)
if !ok {
return
}
pairingID := r.PathValue("pairingID")
operation, ok := s.beginIdentityWriteWithVersion(w, r, "pairing.retire_security_event_conflict", expectedVersion, struct {
PairingID string `json:"pairingId"`
}{PairingID: pairingID})
if !ok {
return
}
defer operation.close()
traceID := ensureIdentityTraceID(w, r)
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "pairing.retire_security_event_conflict", pairingID, traceID)
if !ok {
return
}
pairing, err := s.identityPairing.RetireConflictingSecurityEvents(r.Context(), pairingID, expectedVersion)
if err != nil {
s.writeIdentityError(w, r, "pairing.retire_security_event_conflict", pairingID, traceID, err)
return
}
s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID)
s.startIdentityPairingWorker(pairing.ID)
}
// updateIdentityDraftPolicy godoc
// @Summary 修改统一认证 Draft 策略
// @Tags identity
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param revisionID path string true "Revision ID"
// @Param Idempotency-Key header string true "幂等键"
// @Param If-Match header string true "当前 Revision ETag"
// @Param body body identityPolicyPatch true "Gateway 本地策略"
// @Success 200 {object} identity.Revision
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 412 {object} ErrorEnvelope
// @Failure 428 {object} ErrorEnvelope
// @Router /api/admin/system/identity/revisions/{revisionID} [patch]
func (s *Server) updateIdentityDraftPolicy(w http.ResponseWriter, r *http.Request) {
var patch identityPolicyPatch
if !decodeIdentityRequest(w, r, &patch) {
return
}
expectedVersion, ok := requiredIdentityVersion(w, r)
if !ok {
return
}
operation, ok := s.beginIdentityWriteWithVersion(w, r, "revision.policy", expectedVersion, patch)
if !ok {
return
}
defer operation.close()
revision, err := s.store.IdentityConfigurationRevision(r.Context(), r.PathValue("revisionID"))
if err != nil || revision.Version != expectedVersion {
s.writeIdentityError(w, r, "revision.policy", r.PathValue("revisionID"), ensureIdentityTraceID(w, r), firstIdentityError(err, identity.ErrRevisionConflict))
return
}
policy := identity.RevisionPolicy{
LocalTenantKey: revision.LocalTenantKey, RolePrefix: revision.RolePrefix, JITEnabled: revision.JITEnabled,
LegacyJWTEnabled: revision.LegacyJWTEnabled, SessionIdleSeconds: revision.SessionIdleSeconds,
SessionAbsoluteSeconds: revision.SessionAbsoluteSeconds, SessionRefreshSeconds: revision.SessionRefreshSeconds,
}
applyIdentityPolicyPatch(&policy, patch)
traceID := ensureIdentityTraceID(w, r)
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "revision.policy", revision.ID, traceID)
if !ok {
return
}
updated, err := s.store.UpdateIdentityRevisionPolicy(r.Context(), revision.ID, expectedVersion, policy)
if err != nil {
s.writeIdentityError(w, r, "revision.policy", revision.ID, traceID, err)
return
}
s.completeIdentityWrite(w, r, operation, http.StatusOK, updated, updated.Version, auditID)
}
// validateIdentityRevision godoc
// @Summary 验证统一认证 Revision
// @Tags identity
// @Produce json
// @Security BearerAuth
// @Param revisionID path string true "Revision ID"
// @Param Idempotency-Key header string true "幂等键"
// @Param If-Match header string true "当前 Revision ETag"
// @Success 200 {object} identity.Revision
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 412 {object} ErrorEnvelope
// @Failure 428 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Router /api/admin/system/identity/revisions/{revisionID}/validate [post]
func (s *Server) validateIdentityRevision(w http.ResponseWriter, r *http.Request) {
s.runIdentityRevisionAction(w, r, "revision.validate", func(expected int64, traceID, auditID string) (identity.Revision, error) {
return s.identityRuntime.Validate(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID)
})
}
// activateIdentityRevision godoc
// @Summary 激活统一认证 Revision
// @Description 在完整候选 Runtime 验证成功且本地 Break-glass Manager 可用后原子热切换。
// @Tags identity
// @Produce json
// @Security BearerAuth
// @Param revisionID path string true "Revision ID"
// @Param Idempotency-Key header string true "幂等键"
// @Param If-Match header string true "当前 Revision ETag"
// @Success 200 {object} identity.Revision
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Failure 412 {object} ErrorEnvelope
// @Failure 428 {object} ErrorEnvelope
// @Router /api/admin/system/identity/revisions/{revisionID}/activate [post]
func (s *Server) activateIdentityRevision(w http.ResponseWriter, r *http.Request) {
s.runIdentityRevisionAction(w, r, "revision.activate", func(expected int64, traceID, auditID string) (identity.Revision, error) {
return s.identityRuntime.Activate(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID)
})
}
// rollbackIdentityRevision godoc
// @Summary 请求恢复历史统一认证 Revision
// @Description 当前远端 OAuth/SSF 资源未版本化,接口会拒绝直接回滚并要求禁用后使用新接入码完成交接。
// @Tags identity
// @Produce json
// @Security BearerAuth
// @Param revisionID path string true "Revision ID"
// @Param Idempotency-Key header string true "幂等键"
// @Param If-Match header string true "当前 Revision ETag"
// @Success 200 {object} identity.Revision
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Failure 412 {object} ErrorEnvelope
// @Failure 428 {object} ErrorEnvelope
// @Router /api/admin/system/identity/revisions/{revisionID}/rollback [post]
func (s *Server) rollbackIdentityRevision(w http.ResponseWriter, r *http.Request) {
s.runIdentityRevisionAction(w, r, "revision.rollback", func(expected int64, traceID, auditID string) (identity.Revision, error) {
return s.identityRuntime.Rollback(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID)
})
}
// disableIdentityConfiguration godoc
// @Summary 禁用统一认证
// @Description 将 Revision 转为只读 Superseded 审计历史,清理 BFF Session并继续允许本地管理登录重新接入必须使用新的接入码。
// @Tags identity
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "幂等键"
// @Param If-Match header string true "当前 Active Revision ETag"
// @Success 200 {object} identity.Revision
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Failure 412 {object} ErrorEnvelope
// @Failure 428 {object} ErrorEnvelope
// @Router /api/admin/system/identity/disable [post]
func (s *Server) disableIdentityConfiguration(w http.ResponseWriter, r *http.Request) {
expectedVersion, ok := requiredIdentityVersion(w, r)
if !ok {
return
}
operation, ok := s.beginIdentityWriteWithVersion(w, r, "revision.disable", expectedVersion, struct{}{})
if !ok {
return
}
defer operation.close()
traceID := ensureIdentityTraceID(w, r)
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "revision.disable", "active", traceID)
if !ok {
return
}
disabled, err := s.identityRuntime.Disable(r.Context(), expectedVersion, traceID, auditID)
if err != nil {
s.writeIdentityError(w, r, "revision.disable", "active", traceID, err)
return
}
s.completeIdentityWrite(w, r, operation, http.StatusOK, disabled, disabled.Version, auditID)
}
func (s *Server) runIdentityRevisionAction(w http.ResponseWriter, r *http.Request, action string, run func(int64, string, string) (identity.Revision, error)) {
expectedVersion, ok := requiredIdentityVersion(w, r)
if !ok {
return
}
operation, ok := s.beginIdentityWriteWithVersion(w, r, action, expectedVersion, struct{}{})
if !ok {
return
}
defer operation.close()
traceID := ensureIdentityTraceID(w, r)
auditID, ok := s.requireIdentityConfigurationAudit(w, r, action, r.PathValue("revisionID"), traceID)
if !ok {
return
}
revision, err := run(expectedVersion, traceID, auditID)
if err != nil {
s.writeIdentityError(w, r, action, r.PathValue("revisionID"), traceID, err)
return
}
s.completeIdentityWrite(w, r, operation, http.StatusOK, revision, revision.Version, auditID)
}
func (s *Server) identityRuntimeStatus(r *http.Request) identityRuntimeStatus {
runtime := s.currentIdentityRuntime()
if runtime == nil {
return identityRuntimeStatus{Login: "disabled", JIT: "disabled", TokenIntrospection: "disabled", SessionRevocation: "disabled"}
}
revision := runtime.Revision
status := identityRuntimeStatus{
Enabled: true, RevisionID: revision.ID, Login: capabilityHealth(runtime.PublicClient != nil),
JIT: capabilityHealth(revision.JITEnabled), TokenIntrospection: capabilityHealth(revision.TokenIntrospection),
SessionRevocation: capabilityHealth(revision.SessionRevocation), LastTraceID: revision.LastTraceID,
LastAuditID: revision.LastAuditID, LastErrorCategory: revision.LastErrorCategory,
}
if revision.SessionRevocation && runtime.SecurityEvents != nil {
if connection, err := runtime.SecurityEvents.Get(r.Context()); err == nil && connection.LifecycleStatus != "active" {
status.SessionRevocation = connection.HealthMode
}
}
return status
}
func capabilityHealth(enabled bool) string {
if enabled {
return "healthy"
}
return "disabled"
}
func decodeIdentityRequest(w http.ResponseWriter, r *http.Request, output any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(output); err != nil {
writeError(w, http.StatusBadRequest, "统一认证请求格式无效", "INVALID_REQUEST")
return false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, "统一认证请求格式无效", "INVALID_REQUEST")
return false
}
return true
}
func (s *Server) beginIdentityWrite(w http.ResponseWriter, r *http.Request, operation string, version int64, request any) (*identityWriteOperation, bool) {
if parsed, ok := requiredIdentityVersion(w, r); !ok || parsed != version {
if ok {
writeError(w, http.StatusPreconditionFailed, "统一认证配置版本已变化", "IDENTITY_VERSION_CONFLICT")
}
return nil, false
}
return s.beginIdentityWriteWithVersion(w, r, operation, version, request)
}
func (s *Server) beginIdentityWriteWithVersion(w http.ResponseWriter, r *http.Request, operation string, version int64, request any) (*identityWriteOperation, bool) {
key := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if key == "" || len(key) > 255 {
writeError(w, http.StatusBadRequest, "Idempotency-Key is required", "IDEMPOTENCY_KEY_REQUIRED")
return nil, false
}
requestHash := identityRequestHash(operation, version, request)
s.identityManagementMu.Lock()
write := &identityWriteOperation{operation: operation, key: key, requestHash: requestHash, release: s.identityManagementMu.Unlock}
if s.store == nil {
return write, true
}
recorded, err := s.store.IdentityManagementRequest(r.Context(), operation, key)
if errors.Is(err, store.ErrIdentityManagementRequestNotFound) {
return write, true
}
if err != nil {
write.close()
writeError(w, http.StatusServiceUnavailable, "幂等状态暂时不可用", "IDENTITY_IDEMPOTENCY_UNAVAILABLE")
return nil, false
}
if recorded.RequestHash != requestHash {
write.close()
writeError(w, http.StatusConflict, "Idempotency-Key 已用于其他请求", "IDEMPOTENCY_KEY_REUSED")
return nil, false
}
var response storedIdentityResponse
if json.Unmarshal(recorded.Response, &response) != nil {
write.close()
writeError(w, http.StatusServiceUnavailable, "幂等响应暂时不可用", "IDENTITY_IDEMPOTENCY_UNAVAILABLE")
return nil, false
}
write.close()
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Idempotent-Replayed", "true")
if response.ETag != "" {
w.Header().Set("ETag", response.ETag)
}
if response.AuditID != "" {
w.Header().Set("X-Audit-Id", response.AuditID)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(response.Status)
_, _ = w.Write(response.Body)
return nil, false
}
func identityRequestHash(operation string, version int64, request any) string {
encoded, _ := json.Marshal(request)
digest := sha256.Sum256(append([]byte(fmt.Sprintf("%s\x00%d\x00", operation, version)), encoded...))
return fmt.Sprintf("%x", digest[:])
}
func (s *Server) completeIdentityWrite(w http.ResponseWriter, r *http.Request, operation *identityWriteOperation, status int, payload any, version int64, auditID string) {
body, _ := json.Marshal(payload)
stored, _ := json.Marshal(storedIdentityResponse{Status: status, Body: body, ETag: identityETag(version), AuditID: auditID})
if s.store != nil {
if err := s.store.RecordIdentityManagementRequest(r.Context(), store.IdentityManagementRequest{
Operation: operation.operation, Key: operation.key, RequestHash: operation.requestHash, Response: stored,
}); err != nil && s.logger != nil {
s.logger.ErrorContext(r.Context(), "identity idempotency response could not be recorded", "operation", operation.operation, "error_category", "idempotency_store_failed")
}
}
operation.close()
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("ETag", identityETag(version))
if auditID != "" {
w.Header().Set("X-Audit-Id", auditID)
}
writeJSON(w, status, payload)
}
func requiredIdentityVersion(w http.ResponseWriter, r *http.Request) (int64, bool) {
value := strings.Trim(strings.TrimPrefix(strings.TrimSpace(r.Header.Get("If-Match")), "W/"), `"`)
version, err := strconv.ParseInt(value, 10, 64)
if err != nil || version < 0 {
writeError(w, http.StatusPreconditionRequired, "If-Match is required", "IF_MATCH_REQUIRED")
return 0, false
}
return version, true
}
func identityETag(version int64) string { return fmt.Sprintf(`W/"%d"`, version) }
func applyIdentityPolicyPatch(policy *identity.RevisionPolicy, patch identityPolicyPatch) {
if patch.LocalTenantKey != nil {
policy.LocalTenantKey = strings.TrimSpace(*patch.LocalTenantKey)
}
if patch.RolePrefix != nil {
policy.RolePrefix = strings.TrimSpace(*patch.RolePrefix)
}
if patch.JITEnabled != nil {
policy.JITEnabled = *patch.JITEnabled
}
if patch.LegacyJWTEnabled != nil {
policy.LegacyJWTEnabled = *patch.LegacyJWTEnabled
}
if patch.SessionIdleSeconds != nil {
policy.SessionIdleSeconds = *patch.SessionIdleSeconds
}
if patch.SessionAbsoluteSeconds != nil {
policy.SessionAbsoluteSeconds = *patch.SessionAbsoluteSeconds
}
if patch.SessionRefreshSeconds != nil {
policy.SessionRefreshSeconds = *patch.SessionRefreshSeconds
}
}
func firstIdentityError(actual, fallback error) error {
if actual != nil {
return actual
}
return fallback
}
func ensureIdentityTraceID(w http.ResponseWriter, r *http.Request) string {
return ensureSecurityEventTraceID(w, r)
}
func (s *Server) writeIdentityError(w http.ResponseWriter, r *http.Request, action, targetID, traceID string, err error) {
status, message, code := identityErrorProjection(err)
s.recordIdentityConfigurationAudit(r, action, targetID, "failure", traceID, code)
writeError(w, status, message, code)
}
func identityErrorProjection(err error) (int, string, string) {
var categorized interface{ SafeErrorCategory() string }
safeCategory := ""
if errors.As(err, &categorized) {
safeCategory = categorized.SafeErrorCategory()
}
switch {
case errors.Is(err, identity.ErrRevisionNotFound):
return http.StatusNotFound, "统一认证配置不存在", "IDENTITY_CONFIGURATION_NOT_FOUND"
case errors.Is(err, identity.ErrRevisionConflict):
return http.StatusPreconditionFailed, "统一认证配置版本或状态已变化", "IDENTITY_VERSION_CONFLICT"
case errors.Is(err, identity.ErrBreakGlassRequired):
return http.StatusConflict, "请先保留至少一个可用的本地应急管理员凭据", "BREAK_GLASS_MANAGER_REQUIRED"
case errors.Is(err, identity.ErrLocalTenantInvalid):
return http.StatusConflict, "本地租户映射无效", "IDENTITY_LOCAL_TENANT_INVALID"
case errors.Is(err, identity.ErrPairingInProgress):
return http.StatusConflict, "请先完成或放弃当前统一认证配对", "IDENTITY_PAIRING_IN_PROGRESS"
case errors.Is(err, identity.ErrActiveConfigurationHandoffRequired):
return http.StatusConflict, "当前仍有 Active 统一认证配置;请先禁用,再使用新接入码配对", "IDENTITY_ACTIVE_CONFIGURATION_HANDOFF_REQUIRED"
case errors.Is(err, identity.ErrRollbackConfigurationHandoffRequired):
return http.StatusConflict, "旧版本关联的远端 OAuth/SSF 资源可能已变化;请禁用后使用新接入码恢复", "IDENTITY_ROLLBACK_CONFIGURATION_HANDOFF_REQUIRED"
case errors.Is(err, identity.ErrSecurityEventRetirementPending):
return http.StatusConflict, "旧安全事件 Stream 尚未完成断开;系统会继续重试,请稍后再次禁用", "IDENTITY_SECURITY_EVENT_RETIREMENT_PENDING"
case errors.Is(err, identity.ErrPairingNotCancellable):
return http.StatusConflict, "当前统一认证配置不能放弃", "IDENTITY_PAIRING_NOT_CANCELLABLE"
case errors.Is(err, identity.ErrPairingConflictNotResolvable):
return http.StatusConflict, "当前配对已不需要退役旧安全事件连接,请刷新状态", "IDENTITY_PAIRING_CONFLICT_NOT_RESOLVABLE"
case safeCategory == "credential_handoff_unsafe":
return http.StatusConflict, "旧安全事件连接与本次认证中心不匹配,系统已拒绝发送凭据;请先在原配置下断开旧连接", "IDENTITY_SECURITY_EVENT_HANDOFF_UNSAFE"
case err != nil && (strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required")):
return http.StatusBadRequest, "统一认证配置无效", "IDENTITY_CONFIGURATION_INVALID"
default:
return http.StatusBadGateway, "认证中心或统一认证服务暂时不可用", "IDENTITY_SERVICE_UNAVAILABLE"
}
}
func (s *Server) recordIdentityConfigurationAudit(r *http.Request, action, targetID, outcome, traceID, errorCategory string) string {
if s.store == nil {
return ""
}
actor, _ := auth.UserFromContext(r.Context())
input := store.AuditLogInput{
Category: "identity", Action: "identity." + action, TargetType: "identity_configuration_revision",
TargetID: firstNonEmptyText(targetID, "pending"), RequestIP: limitAuditText(requestIP(r), 128),
UserAgent: limitAuditText(r.UserAgent(), 512), Metadata: map[string]any{
"outcome": outcome, "traceId": traceID, "errorCategory": errorCategory,
},
}
if actor != nil {
input.ActorGatewayUserID = uuidText(firstNonEmptyText(actor.GatewayUserID, actor.ID))
input.ActorUserID, input.ActorUsername, input.ActorSource, input.ActorRoles = actor.ID, actor.Username, actor.Source, actor.Roles
}
audit, err := s.store.RecordAuditLog(r.Context(), input)
if err != nil {
if s.logger != nil {
s.logger.WarnContext(r.Context(), "record identity audit failed", "action", action, "error_category", "audit_store_failed", "trace_id", traceID)
}
return ""
}
return audit.ID
}
func (s *Server) requireIdentityConfigurationAudit(w http.ResponseWriter, r *http.Request, action, targetID, traceID string) (string, bool) {
auditID := s.recordIdentityConfigurationAudit(r, action, targetID, "requested", traceID, "")
if auditID == "" {
writeError(w, http.StatusServiceUnavailable, "统一认证审计暂时不可用,操作未执行", "IDENTITY_AUDIT_UNAVAILABLE")
return "", false
}
w.Header().Set("X-Audit-Id", auditID)
return auditID, true
}
func (s *Server) startIdentityPairingWorker(pairingID string) {
workerContext, cancel := context.WithCancel(s.ctx)
worker := &identityPairingWorker{cancel: cancel, done: make(chan struct{})}
if _, loaded := s.identityPairingWorkers.LoadOrStore(pairingID, worker); loaded {
cancel()
return
}
go func() {
defer func() {
s.identityPairingWorkers.CompareAndDelete(pairingID, worker)
close(worker.done)
}()
delay := time.Second
for {
pairing, err := s.identityPairing.Continue(workerContext, pairingID)
if err == nil {
if pairing.Status == identity.PairingCompleted || pairing.Status == identity.PairingFailed || pairing.Status == identity.PairingExpired || pairing.Status == identity.PairingCancelled {
return
}
delay = time.Second
} else {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return
}
if s.logger != nil {
s.logger.Warn("identity pairing step failed and will retry", "pairing_id", pairingID, "error_category", firstNonEmptyText(pairing.LastErrorCategory, "pairing_step_failed"))
}
if delay < 15*time.Second {
delay *= 2
}
}
select {
case <-workerContext.Done():
return
case <-time.After(delay):
}
}
}()
}
func (s *Server) stopIdentityPairingWorker(pairingID string) <-chan struct{} {
if value, ok := s.identityPairingWorkers.Load(pairingID); ok {
worker := value.(*identityPairingWorker)
worker.cancel()
return worker.done
}
done := make(chan struct{})
close(done)
return done
}
func (s *Server) startIdentityPairingCleanupWorker(pairingID string, processingDone <-chan struct{}) {
if processingDone == nil {
processingDone = s.stopIdentityPairingWorker(pairingID)
}
if _, loaded := s.identityCleanupWorkers.LoadOrStore(pairingID, struct{}{}); loaded {
return
}
go func() {
defer s.identityCleanupWorkers.Delete(pairingID)
if processingDone != nil {
select {
case <-s.ctx.Done():
return
case <-processingDone:
}
}
delay := time.Second
for {
pairing, err := s.identityPairing.Cleanup(s.ctx, pairingID)
if err == nil {
if pairing.CleanupStatus == identity.PairingCleanupCompleted {
return
}
delay = time.Second
} else {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, identity.ErrPairingNotCancellable) {
return
}
if s.logger != nil {
s.logger.Warn("identity pairing cleanup failed and will retry", "pairing_id", pairingID, "error_category", firstNonEmptyText(pairing.LastErrorCategory, "pairing_cleanup_failed"))
}
if delay < 15*time.Second {
delay *= 2
}
}
select {
case <-s.ctx.Done():
return
case <-time.After(delay):
}
}
}()
}

View File

@ -0,0 +1,76 @@
package httpapi
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
)
func TestRequiredIdentityWriteHeaders(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/identity", nil)
recorder := httptest.NewRecorder()
if _, ok := requiredIdentityVersion(recorder, request); ok || recorder.Code != http.StatusPreconditionRequired {
t.Fatalf("missing If-Match status=%d", recorder.Code)
}
request.Header.Set("If-Match", `W/"7"`)
recorder = httptest.NewRecorder()
version, ok := requiredIdentityVersion(recorder, request)
if !ok || version != 7 {
t.Fatalf("weak ETag version=%d ok=%v", version, ok)
}
}
func TestIdentityErrorProjectionProtectsInternalDetails(t *testing.T) {
status, message, code := identityErrorProjection(assertionError("upstream response contained secret-token"))
if status != http.StatusBadGateway || code != "IDENTITY_SERVICE_UNAVAILABLE" || message == "upstream response contained secret-token" {
t.Fatalf("unsafe error projection status=%d code=%q message=%q", status, code, message)
}
}
func TestIdentityPairingRecoveryErrorsUseStableConflictResponses(t *testing.T) {
for _, test := range []struct {
err error
code string
}{
{err: identity.ErrPairingInProgress, code: "IDENTITY_PAIRING_IN_PROGRESS"},
{err: identity.ErrPairingNotCancellable, code: "IDENTITY_PAIRING_NOT_CANCELLABLE"},
{err: identity.ErrPairingConflictNotResolvable, code: "IDENTITY_PAIRING_CONFLICT_NOT_RESOLVABLE"},
} {
status, message, code := identityErrorProjection(test.err)
if status != http.StatusConflict || code != test.code || message == "" || errors.Is(assertionError(message), test.err) {
t.Fatalf("err=%v status=%d code=%q message=%q", test.err, status, code, message)
}
}
}
func TestIdentityWriteHashScopesPairingCancellationToTarget(t *testing.T) {
type cancellationRequest struct {
PairingID string `json:"pairingId"`
}
first := identityRequestHash("pairing.cancel", 7, cancellationRequest{PairingID: "pairing-a"})
second := identityRequestHash("pairing.cancel", 7, cancellationRequest{PairingID: "pairing-b"})
if first == second {
t.Fatal("pairing cancellation idempotency hash must include the target pairing")
}
}
func TestIdentityWriteAuditFailsClosedBeforeMutation(t *testing.T) {
server := &Server{}
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable", nil)
recorder := httptest.NewRecorder()
if _, ok := server.requireIdentityConfigurationAudit(recorder, request, "revision.disable", "active", "trace-test"); ok {
t.Fatal("identity write unexpectedly continued without durable audit storage")
}
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("audit failure status=%d, want 503", recorder.Code)
}
}
type assertionError string
func (err assertionError) Error() string { return string(err) }

View File

@ -0,0 +1,126 @@
package httpapi
import (
"context"
"log/slog"
"sync"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
)
const identityPairingReconcileInterval = 5 * time.Second
type identityPairingReconciliationStore interface {
PendingIdentityPairingExchanges(context.Context) ([]identity.PairingExchange, error)
CompletedIdentityPairingsAwaitingActivation(context.Context) ([]identity.PairingExchange, error)
PendingIdentityPairingCleanups(context.Context) ([]identity.PairingExchange, error)
}
type identityPairingRestorer interface {
RestoreCompletedSecurityEvents(context.Context, string) error
}
func (s *Server) reconcileCanonicalIdentityPairing() {
reconcileCanonicalIdentityPairing(
s.ctx,
s.store,
s.identityPairing,
&s.identityRestoredPairings,
s.startIdentityPairingWorker,
func(pairingID string) { s.startIdentityPairingCleanupWorker(pairingID, nil) },
s.logger,
)
}
func (s *Server) runIdentityPairingCoordinator() {
ticker := time.NewTicker(identityPairingReconcileInterval)
defer ticker.Stop()
for {
select {
case <-s.ctx.Done():
return
case <-ticker.C:
s.reconcileIdentityRuntime()
s.reconcileCanonicalIdentityPairing()
}
}
}
func reconcileCanonicalIdentityPairing(
ctx context.Context,
repository identityPairingReconciliationStore,
restorer identityPairingRestorer,
restored *sync.Map,
startWorker func(string),
startCleanup func(string),
logger *slog.Logger,
) {
cleanups, cleanupErr := repository.PendingIdentityPairingCleanups(ctx)
if cleanupErr != nil {
if logger != nil {
logger.Warn("pending identity pairing cleanups could not be resumed", "error_category", "pairing_cleanup_resume_failed")
}
} else {
for _, pairing := range cleanups {
startCleanup(pairing.ID)
}
}
pending, err := repository.PendingIdentityPairingExchanges(ctx)
if err != nil {
if logger != nil {
logger.Warn("pending identity pairings could not be resumed", "error_category", "pairing_resume_failed")
}
return
}
if len(pending) > 0 {
forgetOtherRestoredPairings(restored, "")
startWorker(pending[0].ID)
return
}
completed, err := repository.CompletedIdentityPairingsAwaitingActivation(ctx)
if err != nil {
if logger != nil {
logger.Warn("completed identity pairings could not be loaded", "error_category", "pairing_receiver_restore_failed")
}
return
}
if len(completed) == 0 {
forgetOtherRestoredPairings(restored, "")
return
}
pairingID := completed[0].ID
forgetOtherRestoredPairings(restored, pairingID)
if _, loaded := restored.LoadOrStore(pairingID, struct{}{}); loaded {
return
}
if err := restorer.RestoreCompletedSecurityEvents(ctx, pairingID); err != nil {
restored.Delete(pairingID)
if logger != nil {
logger.Warn("completed identity pairing receiver could not be restored", "pairing_id", pairingID, "error_category", "pairing_receiver_restore_failed")
}
}
}
func (s *Server) reconcileIdentityRuntime() {
if s.identityRuntime == nil || !s.identityRuntime.ReconciliationRequired() {
return
}
ctx, cancel := context.WithTimeout(s.ctx, 5*time.Second)
defer cancel()
if err := s.identityRuntime.ReconcileActive(ctx); err != nil && s.logger != nil {
s.logger.Warn("identity runtime reconciliation failed and will retry", "error_category", "identity_runtime_reconciliation_failed")
}
}
func forgetOtherRestoredPairings(restored *sync.Map, keepID string) {
restored.Range(func(key, _ any) bool {
if key != keepID {
restored.Delete(key)
}
return true
})
}

View File

@ -0,0 +1,150 @@
package httpapi
import (
"context"
"errors"
"sync"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
)
type identityPairingCoordinatorStore struct {
pending []identity.PairingExchange
completed []identity.PairingExchange
cleanups []identity.PairingExchange
pendingErr error
completedErr error
cleanupErr error
}
func (store identityPairingCoordinatorStore) PendingIdentityPairingExchanges(context.Context) ([]identity.PairingExchange, error) {
return store.pending, store.pendingErr
}
func (store identityPairingCoordinatorStore) CompletedIdentityPairingsAwaitingActivation(context.Context) ([]identity.PairingExchange, error) {
return store.completed, store.completedErr
}
func (store identityPairingCoordinatorStore) PendingIdentityPairingCleanups(context.Context) ([]identity.PairingExchange, error) {
return store.cleanups, store.cleanupErr
}
type identityPairingCoordinatorRestorer struct {
calls int
err error
}
func (restorer *identityPairingCoordinatorRestorer) RestoreCompletedSecurityEvents(context.Context, string) error {
restorer.calls++
return restorer.err
}
func TestReconcileCanonicalIdentityPairingRetriesFailedRestore(t *testing.T) {
repository := identityPairingCoordinatorStore{
completed: []identity.PairingExchange{{ID: "pairing-1"}},
}
restorer := &identityPairingCoordinatorRestorer{err: errors.New("SecretStore temporarily unavailable")}
restored := &sync.Map{}
reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(string) {}, func(string) {}, nil)
reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(string) {}, func(string) {}, nil)
if restorer.calls != 2 {
t.Fatalf("restore calls = %d, want 2", restorer.calls)
}
}
func TestReconcileCanonicalIdentityPairingRestoresSuccessfulPairingOnce(t *testing.T) {
repository := identityPairingCoordinatorStore{
completed: []identity.PairingExchange{{ID: "pairing-1"}},
}
restorer := &identityPairingCoordinatorRestorer{}
restored := &sync.Map{}
reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(string) {}, func(string) {}, nil)
reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(string) {}, func(string) {}, nil)
if restorer.calls != 1 {
t.Fatalf("restore calls = %d, want 1", restorer.calls)
}
}
func TestReconcileCanonicalIdentityPairingPrioritizesPendingWork(t *testing.T) {
repository := identityPairingCoordinatorStore{
pending: []identity.PairingExchange{{ID: "pending-pairing"}},
completed: []identity.PairingExchange{{ID: "completed-pairing"}},
}
restorer := &identityPairingCoordinatorRestorer{}
restored := &sync.Map{}
started := ""
reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(pairingID string) {
started = pairingID
}, func(string) {}, nil)
if started != "pending-pairing" {
t.Fatalf("started pairing = %q, want pending-pairing", started)
}
if restorer.calls != 0 {
t.Fatalf("restore calls = %d, want 0", restorer.calls)
}
}
func TestReconcileCanonicalIdentityPairingForgetsTerminalPairing(t *testing.T) {
restored := &sync.Map{}
restored.Store("terminal-pairing", struct{}{})
reconcileCanonicalIdentityPairing(
context.Background(),
identityPairingCoordinatorStore{},
&identityPairingCoordinatorRestorer{},
restored,
func(string) {},
func(string) {},
nil,
)
if _, ok := restored.Load("terminal-pairing"); ok {
t.Fatal("terminal pairing restore marker was not removed")
}
}
func TestReconcileCanonicalIdentityPairingResumesCommittedCleanup(t *testing.T) {
repository := identityPairingCoordinatorStore{
cleanups: []identity.PairingExchange{{ID: "cancelled-pairing"}},
}
restorer := &identityPairingCoordinatorRestorer{}
restored := &sync.Map{}
startedCleanup := ""
reconcileCanonicalIdentityPairing(
context.Background(),
repository,
restorer,
restored,
func(string) {},
func(pairingID string) { startedCleanup = pairingID },
nil,
)
if startedCleanup != "cancelled-pairing" {
t.Fatalf("started cleanup = %q, want cancelled-pairing", startedCleanup)
}
}
func TestCleanupCoordinatorCancelsPairingWorkerBeforeCleanup(t *testing.T) {
serverContext, cancelServer := context.WithCancel(context.Background())
cancelServer()
workerContext, cancelWorker := context.WithCancel(context.Background())
server := &Server{ctx: serverContext}
server.identityPairingWorkers.Store("pairing-1", &identityPairingWorker{cancel: cancelWorker, done: make(chan struct{})})
server.startIdentityPairingCleanupWorker("pairing-1", nil)
select {
case <-workerContext.Done():
default:
t.Fatal("coordinator cleanup did not cancel the pairing worker first")
}
}

View File

@ -0,0 +1,108 @@
package httpapi
import (
"context"
"net/url"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession"
ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
)
type oidcTokenVerifier interface {
Verify(context.Context, string) (*auth.User, error)
}
// identityRequestRuntime is an immutable request-level snapshot. A handler that
// starts with one runtime keeps using it even when an administrator activates a
// new revision while that request is in flight.
type identityRequestRuntime struct {
Revision identity.Revision
Verifier oidcTokenVerifier
PublicClient oidcPublicClient
Sessions oidcSessionManager
SessionCipher *oidcsession.Cipher
SecurityEvents *ssfreceiver.ConnectionManager
CookieSecure bool
BrowserEnabled bool
}
func (s *Server) currentIdentityRuntime() *identityRequestRuntime {
if s.identityRuntime != nil {
runtime := s.identityRuntime.Current()
if runtime == nil {
return nil
}
return &identityRequestRuntime{
Revision: runtime.Revision, Verifier: runtime.Verifier, PublicClient: runtime.PublicClient,
Sessions: runtime.Sessions, SessionCipher: runtime.SessionCipher, SecurityEvents: runtime.SecurityEvents,
CookieSecure: runtime.CookieSecure, BrowserEnabled: runtime.PublicClient != nil,
}
}
// Compatibility path for focused HTTP tests. NewServer never uses these
// static fields after identity revisions are enabled.
if s.identityTestRevision.ID == "" && s.identityTestRevision.Issuer == "" && s.oidcClient == nil && s.oidcSessions == nil && s.oidcSessionCipher == nil && s.securityEventManager == nil && !s.identityTestBrowserEnabled {
return nil
}
revision := s.identityTestRevision
if revision.State == "" {
revision.State = identity.RevisionActive
}
webBaseURL := revision.WebBaseURL
if webBaseURL == "" {
webBaseURL = s.cfg.WebBaseURL
}
if webBaseURL == "" {
webBaseURL = s.cfg.CORSAllowedOrigin
}
revision.WebBaseURL = webBaseURL
if revision.PublicBaseURL == "" {
revision.PublicBaseURL = s.cfg.PublicBaseURL
}
if revision.SessionAbsoluteSeconds <= 0 {
revision.SessionAbsoluteSeconds = 28800
}
var verifier oidcTokenVerifier
if s.auth != nil {
verifier = s.auth.OIDCVerifier
}
return &identityRequestRuntime{
Revision: revision,
Verifier: verifier, PublicClient: s.oidcClient, Sessions: s.oidcSessions,
SessionCipher: s.oidcSessionCipher, SecurityEvents: s.securityEventManager,
CookieSecure: s.identityTestCookieSecure || strings.HasPrefix(strings.ToLower(revision.PublicBaseURL), "https://"),
BrowserEnabled: s.identityTestBrowserEnabled || s.oidcClient != nil && s.oidcSessions != nil && s.oidcSessionCipher != nil,
}
}
func (s *Server) currentSecurityEventManager() *ssfreceiver.ConnectionManager {
if s.identityRuntime != nil {
return s.identityRuntime.SecurityEventManager()
}
if runtime := s.currentIdentityRuntime(); runtime != nil {
return runtime.SecurityEvents
}
return nil
}
func (s *Server) oidcCookieSecure() bool {
if runtime := s.currentIdentityRuntime(); runtime != nil {
return runtime.CookieSecure
}
return false
}
func originMatchesBaseURL(origin, baseURL string) bool {
originURL, err := url.Parse(strings.TrimSpace(origin))
if err != nil || originURL.User != nil || originURL.Path != "" || originURL.RawQuery != "" || originURL.Fragment != "" {
return false
}
base, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil {
return false
}
return strings.EqualFold(originURL.Scheme, base.Scheme) && strings.EqualFold(originURL.Host, base.Host)
}

View File

@ -0,0 +1,26 @@
package httpapi
import (
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestLocalLoginPolicyAlwaysPreservesBreakGlassManager(t *testing.T) {
serverMain := &Server{cfg: config.Config{IdentityMode: "server-main"}}
if !serverMain.localLoginAllowed(store.GatewayUser{Roles: []string{"manager"}}) {
t.Fatal("server-main mode rejected a local break-glass manager")
}
if !serverMain.localLoginAllowed(store.GatewayUser{Roles: []string{"admin"}}) {
t.Fatal("server-main mode rejected a local break-glass admin")
}
if serverMain.localLoginAllowed(store.GatewayUser{Roles: []string{"user"}}) {
t.Fatal("server-main mode accepted an ordinary local user")
}
hybrid := &Server{cfg: config.Config{IdentityMode: "hybrid"}}
if !hybrid.localLoginAllowed(store.GatewayUser{Roles: []string{"user"}}) {
t.Fatal("hybrid mode rejected an ordinary local user")
}
}

View File

@ -23,10 +23,14 @@ import (
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
const oidcJITTenantID = "6e6d0a0f-8b08-41ca-bda6-f1a58f065bc3"
func TestOIDCJITFullGatewayUserFlowAndSecurityBoundary(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
@ -120,35 +124,32 @@ DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::t
})
baseConfig := config.Config{
AppEnv: "test",
HTTPAddr: ":0",
DatabaseURL: databaseURL,
IdentityMode: "hybrid",
JWTSecret: "test-only-jwt-secret",
OIDCEnabled: true,
OIDCIssuer: issuer,
OIDCAudience: "gateway-api",
OIDCTenantID: "auth-center-test-tenant",
OIDCRolePrefix: "gateway.",
OIDCRequiredScopes: []string{"gateway.access"},
OIDCJWKSCacheTTLSeconds: 60,
OIDCAcceptLegacyHS256: true,
OIDCJITProvisioningEnabled: true,
OIDCGatewayTenantKey: "default",
OIDCBrowserSessionEnabled: true,
OIDCSessionCookieSecure: false,
OIDCClientID: "gateway-public-test",
OIDCRedirectURI: "http://localhost/api/v1/auth/oidc/callback",
OIDCPostLogoutRedirectURI: "http://localhost:5178/",
OIDCSessionEncryptionKey: base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{8}, 32)),
OIDCSessionIdleTTLSeconds: 1800,
OIDCSessionAbsoluteTTLSeconds: 28800,
OIDCSessionRefreshBeforeSeconds: 60,
LocalGeneratedStorageDir: t.TempDir(),
LocalUploadedStorageDir: t.TempDir(),
LocalTempAssetTTLHours: 1,
CORSAllowedOrigin: "http://localhost:5178",
TaskProgressCallbackEnabled: false,
AppEnv: "test",
HTTPAddr: ":0",
DatabaseURL: databaseURL,
IdentityMode: "hybrid",
JWTSecret: "test-only-jwt-secret",
IdentitySecretStore: "file",
IdentitySecretDir: t.TempDir(),
LocalGeneratedStorageDir: t.TempDir(),
LocalUploadedStorageDir: t.TempDir(),
LocalTempAssetTTLHours: 1,
CORSAllowedOrigin: "http://localhost:5178",
TaskProgressCallbackEnabled: false,
}
previous, previousErr := db.ActiveIdentityConfigurationRevision(ctx)
if previousErr != nil && !errors.Is(previousErr, identity.ErrRevisionNotFound) {
t.Fatalf("read previous active identity revision: %v", previousErr)
}
testRevisionIDs := make([]string, 0, 3)
t.Cleanup(func() {
restoreOIDCJITIdentityRevision(t, context.Background(), db, previous, previousErr == nil, testRevisionIDs)
})
activeRevision := prepareOIDCJITRevision(t, ctx, db, baseConfig, issuer, "default", true)
testRevisionIDs = append(testRevisionIDs, activeRevision.ID)
activeRevision, _, err = db.ActivateIdentityRevision(ctx, activeRevision.ID, activeRevision.Version, "oidc-jit-test", "oidc-jit-test")
if err != nil {
t.Fatalf("activate OIDC JIT identity revision: %v", err)
}
server := httptest.NewServer(NewServerWithContext(ctx, baseConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
defer server.Close()
@ -246,17 +247,21 @@ DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::t
t.Fatalf("rejected OIDC tokens created %d local users", rejectedWrites)
}
disabledJITConfig := baseConfig
disabledJITConfig.OIDCJITProvisioningEnabled = false
disabledJITServer := httptest.NewServer(NewServerWithContext(ctx, disabledJITConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
disabledJITRevision := prepareOIDCJITRevision(t, ctx, db, baseConfig, issuer, "default", false)
testRevisionIDs = append(testRevisionIDs, disabledJITRevision.ID)
disabledJITRevision, _, err = db.ActivateIdentityRevision(ctx, disabledJITRevision.ID, disabledJITRevision.Version, "oidc-jit-disabled", "oidc-jit-disabled")
if err != nil {
t.Fatalf("activate disabled-JIT revision: %v", err)
}
disabledJITServer := httptest.NewServer(NewServerWithContext(ctx, baseConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
defer disabledJITServer.Close()
assertOIDCJITError(t, disabledJITServer.URL, signedOIDCJITToken(t, key, issuer, rejectedSubjects[3], nil), http.StatusForbidden, errorCodeGatewayUserNotProvisioned)
missingTenantConfig := baseConfig
missingTenantConfig.OIDCGatewayTenantKey = "missing-tenant-" + suffix
missingTenantServer := httptest.NewServer(NewServerWithContext(ctx, missingTenantConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
defer missingTenantServer.Close()
assertOIDCJITError(t, missingTenantServer.URL, signedOIDCJITToken(t, key, issuer, rejectedSubjects[4], nil), http.StatusServiceUnavailable, errorCodeGatewayTenantUnavailable)
missingTenantRevision := prepareOIDCJITRevision(t, ctx, db, baseConfig, issuer, "missing-tenant-"+suffix, true)
testRevisionIDs = append(testRevisionIDs, missingTenantRevision.ID)
if _, _, activateErr := db.ActivateIdentityRevision(ctx, missingTenantRevision.ID, missingTenantRevision.Version, "oidc-jit-missing-tenant", "oidc-jit-missing-tenant"); !errors.Is(activateErr, identity.ErrLocalTenantInvalid) {
t.Fatalf("missing local tenant activation error = %v, want %v", activateErr, identity.ErrLocalTenantInvalid)
}
if _, err := db.Pool().Exec(ctx, `UPDATE gateway_users SET status = 'disabled' WHERE id = $1::uuid`, me.GatewayUserID); err != nil {
t.Fatalf("disable projected user: %v", err)
@ -271,6 +276,86 @@ DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::t
assertOIDCJITError(t, server.URL, validToken, http.StatusForbidden, errorCodeGatewayUserDisabled)
}
func prepareOIDCJITRevision(t *testing.T, ctx context.Context, db *store.Store, cfg config.Config, issuer, localTenantKey string, jitEnabled bool) identity.Revision {
t.Helper()
draft, err := identity.NewDraft(identity.PairingInput{
AuthCenterURL: issuer, PublicBaseURL: "http://localhost", WebBaseURL: "http://localhost:5178",
LocalTenantKey: localTenantKey, LegacyJWTEnabled: true,
}, "test")
if err != nil {
t.Fatalf("create OIDC JIT draft: %v", err)
}
draft.JITEnabled = jitEnabled
draft, err = db.CreateIdentityConfigurationRevision(ctx, draft)
if err != nil {
t.Fatalf("persist OIDC JIT draft: %v", err)
}
secrets, err := identitySecretStore(cfg)
if err != nil {
t.Fatalf("create identity SecretStore: %v", err)
}
sessionReference := "oidc-jit-session-" + draft.ID
if err := secrets.Put(ctx, sessionReference, bytes.Repeat([]byte{8}, 32)); err != nil {
t.Fatalf("store OIDC JIT session key: %v", err)
}
draft, err = db.ApplyIdentityManifest(ctx, draft.ID, draft.Version, identity.ManifestApplication{
Manifest: identity.ManifestV1{
SchemaVersion: 1, Issuer: issuer, TenantID: oidcJITTenantID, ApplicationID: uuid.NewString(),
Capabilities: []string{"oidc_login", "api_access"}, Audience: "gateway-api", Scopes: []string{"gateway.access"},
Clients: identity.ManifestClients{BrowserLogin: &identity.ManifestClient{ClientID: "gateway-public-test"}},
},
SessionEncryptionKeyRef: sessionReference, TraceID: "oidc-jit-test", AuditID: "oidc-jit-test", AppEnv: "test",
})
if err != nil {
t.Fatalf("apply OIDC JIT manifest: %v", err)
}
draft, err = db.MarkIdentityRevisionValidated(ctx, draft.ID, draft.Version, "oidc-jit-test", "oidc-jit-test")
if err != nil {
t.Fatalf("validate OIDC JIT revision: %v", err)
}
return draft
}
func restoreOIDCJITIdentityRevision(t *testing.T, ctx context.Context, db *store.Store, previous identity.Revision, hadPrevious bool, testRevisionIDs []string) {
t.Helper()
isTestRevision := func(id string) bool {
for _, testID := range testRevisionIDs {
if id == testID {
return true
}
}
return false
}
if active, err := db.ActiveIdentityConfigurationRevision(ctx); err == nil && isTestRevision(active.ID) {
if _, disableErr := db.DisableActiveIdentityRevision(ctx, active.Version, "oidc-jit-cleanup", "oidc-jit-cleanup"); disableErr != nil {
t.Errorf("disable test identity revision during cleanup: %v", disableErr)
return
}
}
if hadPrevious {
refreshed, err := db.IdentityConfigurationRevision(ctx, previous.ID)
if err != nil {
t.Errorf("reload previous identity revision during cleanup: %v", err)
return
}
if refreshed.State == identity.RevisionSuperseded {
refreshed, err = db.MarkIdentityRevisionValidated(ctx, refreshed.ID, refreshed.Version, "oidc-jit-restore", "oidc-jit-restore")
if err == nil {
_, _, err = db.ActivateIdentityRevision(ctx, refreshed.ID, refreshed.Version, "oidc-jit-restore", "oidc-jit-restore")
}
if err != nil {
t.Errorf("restore previous identity revision: %v", err)
return
}
}
}
if len(testRevisionIDs) > 0 {
if _, err := db.Pool().Exec(ctx, `DELETE FROM gateway_identity_configuration_revisions WHERE id = ANY($1::uuid[])`, testRevisionIDs); err != nil {
t.Errorf("delete test identity revisions: %v", err)
}
}
}
var currentOIDCTestNonce string
func createOIDCBFFSessionCookie(t *testing.T, baseURL string) *http.Cookie {
@ -364,7 +449,7 @@ func signedOIDCJITToken(t *testing.T, key *ecdsa.PrivateKey, issuer string, subj
t.Helper()
now := time.Now()
claims := jwt.MapClaims{
"iss": issuer, "aud": "gateway-api", "sub": subject, "tid": "auth-center-test-tenant",
"iss": issuer, "aud": "gateway-api", "sub": subject, "tid": oidcJITTenantID,
"preferred_username": "oidc-jit-acceptance", "roles": []string{"gateway.admin"},
"scope": "openid gateway.access", "iat": now.Unix(), "nbf": now.Add(-time.Second).Unix(),
"exp": now.Add(time.Hour).Unix(),

View File

@ -40,7 +40,8 @@ const (
// @Failure 404 {object} ErrorEnvelope
// @Router /api/v1/auth/oidc/login [get]
func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
if !s.oidcBrowserSessionReady() {
runtime := s.currentIdentityRuntime()
if !oidcRuntimeReady(runtime) {
writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled)
return
}
@ -53,13 +54,13 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "登录后返回地址无效", errorCodeOIDCLoginInvalid)
return
}
encoded, err := s.oidcSessionCipher.EncodeLoginTransaction(transaction)
encoded, err := runtime.SessionCipher.EncodeLoginTransaction(transaction)
if err != nil {
s.logger.ErrorContext(r.Context(), "encode OIDC login transaction failed", "error", err)
writeError(w, http.StatusServiceUnavailable, "登录会话初始化失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable)
return
}
authorizationURL, err := s.oidcClient.AuthorizationURL(r.Context(), transaction.State, transaction.Nonce, transaction.PKCEVerifier)
authorizationURL, err := runtime.PublicClient.AuthorizationURL(r.Context(), transaction.State, transaction.Nonce, transaction.PKCEVerifier)
if err != nil {
s.logger.ErrorContext(r.Context(), "load OIDC authorization endpoint failed", "error", err)
writeError(w, http.StatusServiceUnavailable, "认证中心暂时不可用,请稍后重试", "OIDC_AUTHORIZATION_UNAVAILABLE")
@ -68,7 +69,7 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: oidcsession.LoginTransactionCookieName, Value: encoded,
Path: s.oidcCallbackCookiePath(), MaxAge: 600, Expires: time.Now().Add(10 * time.Minute),
HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteLaxMode,
HttpOnly: true, Secure: runtime.CookieSecure, SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, authorizationURL, http.StatusSeeOther)
@ -84,7 +85,8 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
// @Failure 503 {object} ErrorEnvelope
// @Router /api/v1/auth/oidc/callback [get]
func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
if !s.oidcBrowserSessionReady() {
runtime := s.currentIdentityRuntime()
if !oidcRuntimeReady(runtime) {
writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled)
return
}
@ -94,7 +96,7 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
s.writeOIDCLoginTransactionError(w, r, "登录事务无效或已过期", oidcLoginFailureCookieMissing)
return
}
transaction, err := s.oidcSessionCipher.DecodeLoginTransaction(cookie.Value, time.Now())
transaction, err := runtime.SessionCipher.DecodeLoginTransaction(cookie.Value, time.Now())
if err != nil {
s.writeOIDCLoginTransactionError(w, r, "登录事务校验失败,请重新登录", oidcLoginFailureTransactionInvalid)
return
@ -107,22 +109,22 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
s.writeOIDCLoginTransactionError(w, r, "认证中心回调缺少必要参数,请重新登录", oidcLoginFailureAuthorizationResponseMissing)
return
}
tokens, err := s.oidcClient.ExchangeCode(r.Context(), r.URL.Query().Get("code"), transaction.PKCEVerifier)
tokens, err := runtime.PublicClient.ExchangeCode(r.Context(), r.URL.Query().Get("code"), transaction.PKCEVerifier)
if err != nil || tokens.AccessToken == "" || tokens.RefreshToken == "" || tokens.IDToken == "" {
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心登录结果无效,请重新登录", errorCodeOIDCTokenExchangeFailed)
return
}
identity, err := s.auth.AuthenticateOIDCAccessToken(r.Context(), tokens.AccessToken)
identity, err := runtime.Verifier.Verify(r.Context(), tokens.AccessToken)
if err != nil || identity == nil {
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心访问令牌校验失败", errorCodeOIDCTokenExchangeFailed)
return
}
idSubject, err := s.oidcClient.VerifyIDToken(r.Context(), tokens.IDToken, transaction.Nonce)
idSubject, err := runtime.PublicClient.VerifyIDToken(r.Context(), tokens.IDToken, transaction.Nonce)
if err != nil || idSubject != identity.ID {
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心身份令牌校验失败", errorCodeOIDCTokenExchangeFailed)
return
}
projection, err := s.resolveOIDCUserProjection(r.Context(), r, identity)
projection, err := s.resolveOIDCUserProjectionForRevision(r.Context(), r, identity, runtime.Revision)
if err != nil {
s.writeOIDCCallbackProjectionError(w, r, err)
return
@ -131,7 +133,7 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
s.writeOIDCUserResolutionError(w, r, errors.New("OIDC user resolver returned no local user"))
return
}
rawSession, err := s.oidcSessions.Create(r.Context(), oidcsession.TokenBundle{
rawSession, err := runtime.Sessions.Create(r.Context(), oidcsession.TokenBundle{
AccessToken: tokens.AccessToken, RefreshToken: tokens.RefreshToken, IDToken: tokens.IDToken,
}, projection.User)
if err != nil {
@ -141,12 +143,12 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
now := time.Now()
http.SetCookie(w, &http.Cookie{
Name: auth.OIDCSessionCookieName, Value: rawSession, Path: "/",
MaxAge: s.cfg.OIDCSessionAbsoluteTTLSeconds, Expires: now.Add(time.Duration(s.cfg.OIDCSessionAbsoluteTTLSeconds) * time.Second),
HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteStrictMode,
MaxAge: runtime.Revision.SessionAbsoluteSeconds, Expires: now.Add(time.Duration(runtime.Revision.SessionAbsoluteSeconds) * time.Second),
HttpOnly: true, Secure: runtime.CookieSecure, SameSite: http.SameSiteStrictMode,
})
w.Header().Set("Cache-Control", "no-store")
s.recordOIDCSessionAudit(r, projection.User)
http.Redirect(w, r, s.oidcReturnLocation(transaction.ReturnTo), http.StatusSeeOther)
http.Redirect(w, r, oidcReturnLocation(runtime.Revision.WebBaseURL, transaction.ReturnTo), http.StatusSeeOther)
}
// logoutOIDCSession godoc
@ -158,13 +160,14 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
// @Failure 503 {object} ErrorEnvelope
// @Router /api/v1/auth/oidc/logout [post]
func (s *Server) logoutOIDCSession(w http.ResponseWriter, r *http.Request) {
if !s.oidcBrowserSessionReady() {
runtime := s.currentIdentityRuntime()
if !oidcRuntimeReady(runtime) {
writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled)
return
}
var bundle oidcsession.TokenBundle
if cookie, err := r.Cookie(auth.OIDCSessionCookieName); err == nil {
bundle, err = s.oidcSessions.Delete(r.Context(), cookie.Value)
bundle, err = runtime.Sessions.Delete(r.Context(), cookie.Value)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "登录会话存储暂时不可用", errorCodeOIDCSessionStoreUnavailable)
return
@ -172,14 +175,14 @@ func (s *Server) logoutOIDCSession(w http.ResponseWriter, r *http.Request) {
}
s.clearOIDCSessionCookie(w)
if bundle.RefreshToken != "" {
if err := s.oidcClient.RevokeRefreshToken(r.Context(), bundle.RefreshToken); err != nil {
if err := runtime.PublicClient.RevokeRefreshToken(r.Context(), bundle.RefreshToken); err != nil {
s.logger.WarnContext(r.Context(), "revoke OIDC refresh token failed", "error", err)
}
}
// Do not put the encrypted-at-rest ID Token into a browser-visible redirect URL.
location, err := s.oidcClient.EndSessionURL(r.Context(), "")
location, err := runtime.PublicClient.EndSessionURL(r.Context(), "")
if err != nil {
location = s.cfg.OIDCPostLogoutRedirectURI
location = runtime.Revision.WebBaseURL + "/"
}
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, location, http.StatusSeeOther)
@ -192,9 +195,10 @@ func (s *Server) logoutOIDCSession(w http.ResponseWriter, r *http.Request) {
// @Failure 503 {object} ErrorEnvelope
// @Router /api/v1/auth/oidc/session [delete]
func (s *Server) deleteOIDCBrowserSession(w http.ResponseWriter, r *http.Request) {
if s.oidcSessions != nil {
runtime := s.currentIdentityRuntime()
if runtime != nil && runtime.Sessions != nil {
if cookie, err := r.Cookie(auth.OIDCSessionCookieName); err == nil {
if _, err := s.oidcSessions.Delete(r.Context(), cookie.Value); err != nil {
if _, err := runtime.Sessions.Delete(r.Context(), cookie.Value); err != nil {
writeError(w, http.StatusServiceUnavailable, "登录会话存储暂时不可用", errorCodeOIDCSessionStoreUnavailable)
return
}
@ -206,33 +210,40 @@ func (s *Server) deleteOIDCBrowserSession(w http.ResponseWriter, r *http.Request
}
func (s *Server) oidcBrowserSessionReady() bool {
return s.cfg.OIDCEnabled && s.cfg.OIDCBrowserSessionEnabled && s.auth != nil && s.auth.OIDCVerifier != nil &&
s.oidcClient != nil && s.oidcSessions != nil && s.oidcSessionCipher != nil
return oidcRuntimeReady(s.currentIdentityRuntime())
}
func oidcRuntimeReady(runtime *identityRequestRuntime) bool {
return runtime != nil && runtime.BrowserEnabled && runtime.Verifier != nil && runtime.PublicClient != nil && runtime.Sessions != nil && runtime.SessionCipher != nil
}
func (s *Server) clearOIDCLoginCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: oidcsession.LoginTransactionCookieName, Value: "", Path: s.oidcCallbackCookiePath(),
Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteLaxMode,
Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.oidcCookieSecure(), SameSite: http.SameSiteLaxMode,
})
}
func (s *Server) oidcCallbackCookiePath() string {
if parsed, err := url.Parse(strings.TrimSpace(s.cfg.OIDCRedirectURI)); err == nil && strings.HasPrefix(parsed.Path, "/") {
return parsed.Path
}
return "/api/v1/auth/oidc/callback"
}
func (s *Server) clearOIDCSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: auth.OIDCSessionCookieName, Value: "", Path: "/",
Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteStrictMode,
Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.oidcCookieSecure(), SameSite: http.SameSiteStrictMode,
})
}
func (s *Server) oidcReturnLocation(returnTo string) string {
if base := strings.TrimRight(strings.TrimSpace(s.cfg.WebBaseURL), "/"); base != "" {
if runtime := s.currentIdentityRuntime(); runtime != nil {
return oidcReturnLocation(runtime.Revision.WebBaseURL, returnTo)
}
return returnTo
}
func oidcReturnLocation(webBaseURL, returnTo string) string {
if base := strings.TrimRight(strings.TrimSpace(webBaseURL), "/"); base != "" {
return base + returnTo
}
return returnTo
@ -255,7 +266,10 @@ func (s *Server) writeOIDCLoginTransactionError(w http.ResponseWriter, r *http.R
}
func (s *Server) writeOIDCCallbackErrorWithDiagnostics(w http.ResponseWriter, r *http.Request, status int, message, code, reason, diagnosticID string) {
base := strings.TrimRight(strings.TrimSpace(s.cfg.WebBaseURL), "/")
base := ""
if runtime := s.currentIdentityRuntime(); runtime != nil {
base = strings.TrimRight(strings.TrimSpace(runtime.Revision.WebBaseURL), "/")
}
if parsed, err := url.Parse(base); base != "" && err == nil && parsed.Host != "" && (parsed.Scheme == "https" || parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")) {
query := parsed.Query()
query.Set("oidcError", code)
@ -320,9 +334,6 @@ func (s *Server) recordOIDCSessionAudit(r *http.Request, user *auth.User) {
}
func (s *Server) startOIDCSessionCleanup(ctx context.Context) {
if s.oidcSessions == nil {
return
}
go func() {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
@ -331,7 +342,11 @@ func (s *Server) startOIDCSessionCleanup(ctx context.Context) {
case <-ctx.Done():
return
case <-ticker.C:
if _, err := s.oidcSessions.Cleanup(ctx); err != nil {
runtime := s.currentIdentityRuntime()
if runtime == nil || runtime.Sessions == nil {
continue
}
if _, err := runtime.Sessions.Cleanup(ctx); err != nil {
s.logger.WarnContext(ctx, "cleanup expired OIDC sessions failed", "error", err)
}
}
@ -341,7 +356,8 @@ func (s *Server) startOIDCSessionCleanup(ctx context.Context) {
func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.cfg.OIDCEnabled || !s.cfg.OIDCBrowserSessionEnabled || isSafeHTTPMethod(r.Method) || hasExplicitCredential(r) {
runtime := s.currentIdentityRuntime()
if runtime == nil || !runtime.BrowserEnabled || hasExplicitCredential(r) {
next.ServeHTTP(w, r)
return
}
@ -350,7 +366,7 @@ func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler {
return
}
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" || !originAllowed(origin, s.cfg.CORSAllowedOrigin) {
if origin != "" && !originMatchesBaseURL(origin, runtime.Revision.WebBaseURL) || origin == "" && !isSafeHTTPMethod(r.Method) {
writeError(w, http.StatusForbidden, "browser session request origin was rejected", errorCodeOIDCSessionCSRF)
return
}
@ -368,8 +384,16 @@ func isSafeHTTPMethod(method string) bool {
}
func hasExplicitCredential(r *http.Request) bool {
return strings.TrimSpace(r.Header.Get("Authorization")) != "" ||
return extractBearerCredential(r.Header.Get("Authorization")) != "" ||
strings.TrimSpace(r.Header.Get("x-comfy-api-key")) != "" ||
strings.TrimSpace(r.Header.Get("x-goog-api-key")) != "" ||
strings.TrimSpace(r.URL.Query().Get("key")) != ""
strings.HasPrefix(strings.TrimSpace(r.URL.Query().Get("key")), "sk-")
}
func extractBearerCredential(value string) string {
fields := strings.Fields(value)
if len(fields) == 2 && strings.EqualFold(fields[0], "bearer") {
return fields[1]
}
return ""
}

View File

@ -14,6 +14,7 @@ import (
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession"
)
@ -21,10 +22,9 @@ func TestStartOIDCLoginSetsEncryptedLaxTransactionAndRedirectsWithPKCE(t *testin
cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
client := &fakeOIDCClient{authorizationURL: "https://auth.example.com/authorize?request=redacted"}
server := &Server{
cfg: config.Config{OIDCEnabled: true, OIDCBrowserSessionEnabled: true, OIDCSessionCookieSecure: true},
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: client,
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
identityTestCookieSecure: true, logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?returnTo=%2Fworkspace%3Ftab%3Dwallet", nil)
recorder := httptest.NewRecorder()
@ -50,7 +50,6 @@ func TestStartOIDCLoginSetsEncryptedLaxTransactionAndRedirectsWithPKCE(t *testin
func TestStartOIDCLoginRejectsOpenRedirect(t *testing.T) {
cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
server := &Server{
cfg: config.Config{OIDCEnabled: true, OIDCBrowserSessionEnabled: true},
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: &fakeOIDCClient{},
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher, logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
@ -86,10 +85,7 @@ func TestCompleteOIDCLoginReportsSafeTransactionFailureReason(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
var logs bytes.Buffer
server := &Server{
cfg: config.Config{
OIDCEnabled: true, OIDCBrowserSessionEnabled: true,
WebBaseURL: "http://localhost:5178",
},
cfg: config.Config{WebBaseURL: "http://localhost:5178"},
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: &fakeOIDCClient{},
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher,
logger: slog.New(slog.NewJSONHandler(&logs, nil)),
@ -134,7 +130,7 @@ func TestCompleteOIDCLoginReportsSafeTransactionFailureReason(t *testing.T) {
func TestDeleteOIDCBrowserSessionIsIdempotentAndExpiresCookie(t *testing.T) {
sessions := &fakeOIDCSessions{}
server := &Server{cfg: config.Config{OIDCSessionCookieSecure: true}, oidcSessions: sessions}
server := &Server{oidcSessions: sessions, identityTestCookieSecure: true}
request := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/oidc/session", nil)
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "opaque-session"})
recorder := httptest.NewRecorder()
@ -151,7 +147,11 @@ func TestDeleteOIDCBrowserSessionIsIdempotentAndExpiresCookie(t *testing.T) {
}
func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) {
server := &Server{cfg: config.Config{OIDCEnabled: true, OIDCBrowserSessionEnabled: true, CORSAllowedOrigin: "https://gateway.example.com"}}
server := &Server{
cfg: config.Config{CORSAllowedOrigin: "https://gateway.example.com"},
identityTestRevision: identity.Revision{WebBaseURL: "https://gateway.example.com"},
identityTestBrowserEnabled: true,
}
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
handler := server.protectOIDCSessionCookie(next)
for _, test := range []struct {
@ -161,6 +161,7 @@ func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) {
}{
{name: "missing origin", method: http.MethodPost, wantStatus: http.StatusForbidden},
{name: "foreign origin", method: http.MethodDelete, origin: "https://evil.example", wantStatus: http.StatusForbidden},
{name: "foreign origin cannot read cookie authenticated data", method: http.MethodGet, origin: "https://evil.example", wantStatus: http.StatusForbidden},
{name: "allowed origin", method: http.MethodPatch, origin: "https://gateway.example.com", wantStatus: http.StatusNoContent},
{name: "safe request", method: http.MethodGet, wantStatus: http.StatusNoContent},
{name: "explicit bearer bypasses cookie csrf", method: http.MethodPost, bearer: true, wantStatus: http.StatusNoContent},
@ -181,8 +182,96 @@ func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) {
}
}
func TestOIDCSessionCSRFMalformedAuthorizationCannotBypassForeignOrigin(t *testing.T) {
authenticator := auth.New("local-jwt-secret", "", "")
authenticator.OIDCSessionResolver = func(context.Context, string) (*auth.User, error) {
return &auth.User{ID: "manager", Source: "gateway", Roles: []string{"manager"}}, nil
}
server := &Server{
auth: authenticator,
identityTestRevision: identity.Revision{WebBaseURL: "https://gateway.example.com"},
identityTestBrowserEnabled: true,
}
called := false
handler := server.protectOIDCSessionCookie(server.requireAdmin(auth.PermissionManager, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
})))
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable", nil)
request.Header.Set("Origin", "https://evil.example.com")
request.Header.Set("Authorization", "malformed")
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "manager-session"})
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden || called {
t.Fatalf("malformed Authorization CSRF status=%d handler_called=%t", recorder.Code, called)
}
}
func TestAdminRouteRejectsManagerJWTInQueryAndAcceptsAuthorizationHeader(t *testing.T) {
authenticator := auth.New("local-jwt-secret", "", "")
managerToken, err := authenticator.SignJWT(&auth.User{ID: "manager", Source: "gateway", Roles: []string{"manager"}}, time.Hour)
if err != nil {
t.Fatal(err)
}
server := &Server{auth: authenticator}
handler := server.requireAdmin(auth.PermissionManager, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
queryRequest := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable?key="+managerToken, nil)
queryRecorder := httptest.NewRecorder()
handler.ServeHTTP(queryRecorder, queryRequest)
if queryRecorder.Code != http.StatusUnauthorized {
t.Fatalf("query manager JWT status=%d, want 401", queryRecorder.Code)
}
headerRequest := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable", nil)
headerRequest.Header.Set("Authorization", "Bearer "+managerToken)
headerRecorder := httptest.NewRecorder()
handler.ServeHTTP(headerRecorder, headerRequest)
if headerRecorder.Code != http.StatusNoContent {
t.Fatalf("Authorization manager JWT status=%d, want 204", headerRecorder.Code)
}
}
func TestCORSUsesOnlyCurrentActiveIdentityWebOriginWithoutRestart(t *testing.T) {
server := &Server{
cfg: config.Config{CORSAllowedOrigin: "https://bootstrap.example.com"},
identityTestRevision: identity.Revision{
ID: "active-revision", State: identity.RevisionActive, WebBaseURL: "https://gateway.example.com",
},
}
handler := server.cors(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }))
request := func(origin string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodOptions, "/api/admin/system/identity/configuration", nil)
r.Header.Set("Origin", origin)
r.Header.Set("Access-Control-Request-Method", http.MethodGet)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
return w
}
active := request("https://gateway.example.com")
if active.Header().Get("Access-Control-Allow-Origin") != "https://gateway.example.com" || active.Header().Get("Access-Control-Allow-Credentials") != "true" {
t.Fatalf("active Web origin headers=%v", active.Header())
}
if evil := request("https://evil.example.com"); evil.Header().Get("Access-Control-Allow-Origin") != "" {
t.Fatalf("evil origin was allowed: headers=%v", evil.Header())
}
server.identityTestRevision = identity.Revision{}
if disabled := request("https://gateway.example.com"); disabled.Header().Get("Access-Control-Allow-Origin") != "" {
t.Fatalf("disabled Revision retained dynamic origin: headers=%v", disabled.Header())
}
if bootstrap := request("https://bootstrap.example.com"); bootstrap.Header().Get("Access-Control-Allow-Origin") != "https://bootstrap.example.com" {
t.Fatalf("deployment bootstrap origin stopped working: headers=%v", bootstrap.Header())
}
}
func TestOIDCSessionCSRFIsInactiveWhenOIDCIsDisabled(t *testing.T) {
server := &Server{cfg: config.Config{OIDCEnabled: false, OIDCBrowserSessionEnabled: true}}
server := &Server{}
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "irrelevant-cookie"})
recorder := httptest.NewRecorder()

View File

@ -7,6 +7,7 @@ import (
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
@ -52,17 +53,25 @@ func (s *Server) resolveGatewayUser(next http.Handler) http.Handler {
}
func (s *Server) resolveOIDCUserProjection(ctx context.Context, r *http.Request, user *auth.User) (store.ResolveOrProvisionOIDCUserResult, error) {
runtime := s.currentIdentityRuntime()
if runtime == nil {
return store.ResolveOrProvisionOIDCUserResult{}, errors.New("active identity runtime is unavailable")
}
return s.resolveOIDCUserProjectionForRevision(ctx, r, user, runtime.Revision)
}
func (s *Server) resolveOIDCUserProjectionForRevision(ctx context.Context, r *http.Request, user *auth.User, revision identity.Revision) (store.ResolveOrProvisionOIDCUserResult, error) {
if s.oidcUserResolver == nil {
return store.ResolveOrProvisionOIDCUserResult{}, errors.New("OIDC user resolver is unavailable")
}
return s.oidcUserResolver.ResolveOrProvisionOIDCUser(ctx, store.ResolveOrProvisionOIDCUserInput{
Issuer: s.cfg.OIDCIssuer,
Issuer: revision.Issuer,
Subject: user.ID,
Username: user.Username,
Roles: user.Roles,
TenantID: user.TenantID,
GatewayTenantKey: s.cfg.OIDCGatewayTenantKey,
ProvisioningEnabled: s.cfg.OIDCJITProvisioningEnabled,
GatewayTenantKey: revision.LocalTenantKey,
ProvisioningEnabled: revision.JITEnabled,
RequestIP: limitAuditText(requestIP(r), 128),
UserAgent: limitAuditText(r.UserAgent(), 512),
})

View File

@ -11,7 +11,7 @@ import (
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
@ -41,10 +41,8 @@ func TestResolveGatewayUserAddsLocalOIDCContext(t *testing.T) {
UserGroupID: "6dcf86f2-8eaf-4b43-8e69-181315db24f0",
}}}
server := &Server{
cfg: config.Config{
OIDCIssuer: "https://auth.test.example/realms/easyai",
OIDCGatewayTenantKey: "default",
OIDCJITProvisioningEnabled: true,
identityTestRevision: identity.Revision{
Issuer: "https://auth.test.example/issuer", LocalTenantKey: "default", JITEnabled: true,
},
oidcUserResolver: resolver,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
@ -116,9 +114,9 @@ func TestResolveGatewayUserReturnsStructuredErrors(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := &Server{
cfg: config.Config{OIDCIssuer: "https://auth.test.example", OIDCGatewayTenantKey: "default"},
oidcUserResolver: &fakeOIDCUserResolver{err: test.err},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
identityTestRevision: identity.Revision{Issuer: "https://auth.test.example", LocalTenantKey: "default"},
oidcUserResolver: &fakeOIDCUserResolver{err: test.err},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
request = request.WithContext(auth.WithUser(request.Context(), &auth.User{

View File

@ -0,0 +1,385 @@
package httpapi
import (
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
"github.com/google/uuid"
)
type securityEventConnectionRequest struct {
TransmitterIssuer string `json:"transmitter_issuer"`
ManagementClientID string `json:"management_client_id"`
ManagementClientSecret string `json:"management_client_secret"`
}
type securityEventConnectionResponse struct {
Connected bool `json:"connected"`
Connection ssfreceiver.ConnectionView `json:"connection"`
TraceID string `json:"traceId,omitempty"`
AuditID string `json:"auditId,omitempty"`
}
func (s *Server) getSecurityEventConnection(w http.ResponseWriter, r *http.Request) {
ensureSecurityEventTraceID(w, r)
w.Header().Set("Cache-Control", "no-store")
manager := s.currentSecurityEventManager()
if manager == nil {
writeJSON(w, http.StatusOK, map[string]any{"connected": false, "lifecycleStatus": "disconnected", "prerequisites": s.securityEventPrerequisites()})
return
}
connection, err := manager.Get(r.Context())
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
writeJSON(w, http.StatusOK, map[string]any{"connected": false, "lifecycleStatus": "disconnected", "prerequisites": s.securityEventPrerequisites()})
return
}
if err != nil {
writeError(w, http.StatusServiceUnavailable, "security event connection state is unavailable", "security_event_state_unavailable")
return
}
w.Header().Set("ETag", fmt.Sprintf(`W/"%d"`, connection.Version))
writeJSON(w, http.StatusOK, map[string]any{"connected": true, "connection": connection, "prerequisites": s.securityEventPrerequisites()})
}
func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Request) {
traceID := ensureSecurityEventTraceID(w, r)
manager := s.currentSecurityEventManager()
if manager == nil {
writeError(w, http.StatusConflict, "OIDC must be configured before connecting security events", "security_event_prerequisite_missing")
return
}
idempotencyKey, ok := requiredConnectionIdempotencyKey(w, r)
if !ok {
return
}
var request securityEventConnectionRequest
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil || strings.TrimSpace(request.TransmitterIssuer) == "" {
writeError(w, http.StatusBadRequest, "invalid security event connection request", "invalid_request")
return
}
request.TransmitterIssuer = strings.TrimRight(strings.TrimSpace(request.TransmitterIssuer), "/")
request.ManagementClientID = strings.TrimSpace(request.ManagementClientID)
if (request.ManagementClientID == "") != (request.ManagementClientSecret == "") || len(request.ManagementClientID) > 200 || len(request.ManagementClientSecret) > 512 {
writeError(w, http.StatusBadRequest, "machine Client ID and Secret must be provided together", "invalid_machine_credential")
return
}
secret := []byte(request.ManagementClientSecret)
defer clear(secret)
secretDigest := sha256.Sum256(secret)
requestHash := securityEventOperationHash("connect", request.TransmitterIssuer+"\x00"+request.ManagementClientID+"\x00"+fmt.Sprintf("%x", secretDigest[:]))
s.securityEventManagementMu.Lock()
defer s.securityEventManagementMu.Unlock()
if s.replaySecurityEventOperation(w, r, "connect", idempotencyKey, requestHash) {
return
}
current, currentErr := manager.Get(r.Context())
switch {
case currentErr == nil:
if !matchConnectionVersion(w, r, current.Version) {
return
}
case errors.Is(currentErr, store.ErrSecurityEventConnectionNotFound):
current = ssfreceiver.ConnectionView{}
if !matchConnectionVersion(w, r, 0) {
return
}
default:
writeError(w, http.StatusServiceUnavailable, "security event connection state is unavailable", "security_event_state_unavailable")
return
}
auditID, ok := s.requireSecurityEventConnectionAudit(w, r, "connect", traceID, current)
if !ok {
return
}
connection, err := manager.Connect(r.Context(), request.TransmitterIssuer, request.ManagementClientID, secret, idempotencyKey)
if err != nil {
s.writeSecurityEventConnectionError(w, r, manager, "connect", traceID, err)
return
}
s.writeSecurityEventOperation(w, r, "connect", idempotencyKey, requestHash, traceID, auditID, connection)
}
func (s *Server) verifySecurityEventConnection(w http.ResponseWriter, r *http.Request) {
traceID := ensureSecurityEventTraceID(w, r)
s.securityEventManagementMu.Lock()
defer s.securityEventManagementMu.Unlock()
manager := s.currentSecurityEventManager()
idempotencyKey, requestHash, auditID, ok := s.beginSecurityEventOperation(w, r, manager, "verify", traceID)
if !ok {
return
}
connection, err := manager.Verify(r.Context())
if err != nil {
s.writeSecurityEventConnectionError(w, r, manager, "verify", traceID, err)
return
}
s.writeSecurityEventOperation(w, r, "verify", idempotencyKey, requestHash, traceID, auditID, connection)
}
func (s *Server) rotateSecurityEventConnectionCredential(w http.ResponseWriter, r *http.Request) {
traceID := ensureSecurityEventTraceID(w, r)
s.securityEventManagementMu.Lock()
defer s.securityEventManagementMu.Unlock()
manager := s.currentSecurityEventManager()
idempotencyKey, requestHash, auditID, ok := s.beginSecurityEventOperation(w, r, manager, "rotate", traceID)
if !ok {
return
}
connection, err := manager.RotateCredential(r.Context())
if err != nil {
s.writeSecurityEventConnectionError(w, r, manager, "rotate", traceID, err)
return
}
s.writeSecurityEventOperation(w, r, "rotate", idempotencyKey, requestHash, traceID, auditID, connection)
}
func (s *Server) deleteSecurityEventConnection(w http.ResponseWriter, r *http.Request) {
traceID := ensureSecurityEventTraceID(w, r)
s.securityEventManagementMu.Lock()
defer s.securityEventManagementMu.Unlock()
manager := s.currentSecurityEventManager()
idempotencyKey, requestHash, auditID, ok := s.beginSecurityEventOperation(w, r, manager, "disconnect", traceID)
if !ok {
return
}
connection, err := manager.Disconnect(r.Context())
if err != nil {
s.writeSecurityEventConnectionError(w, r, manager, "disconnect", traceID, err)
return
}
s.writeSecurityEventOperation(w, r, "disconnect", idempotencyKey, requestHash, traceID, auditID, connection)
}
func (s *Server) securityEventPrerequisites() map[string]any {
runtime := s.currentIdentityRuntime()
if runtime == nil {
return map[string]any{
"oidcConfigured": false, "introspectionClientConfigured": false,
"publicBaseUrlConfigured": false, "managementClientId": "", "credentialInputSupported": false,
}
}
revision := runtime.Revision
return map[string]any{
"oidcConfigured": revision.Issuer != "" && revision.TenantID != "",
"introspectionClientConfigured": revision.MachineClientID != "" && revision.MachineCredentialRef != "",
"publicBaseUrlConfigured": revision.PublicBaseURL != "",
"managementClientId": revision.MachineClientID,
"credentialInputSupported": false,
}
}
func requiredConnectionIdempotencyKey(w http.ResponseWriter, r *http.Request) (string, bool) {
value := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if value == "" || len(value) > 255 {
writeError(w, http.StatusBadRequest, "Idempotency-Key is required", "idempotency_key_required")
return "", false
}
return value, true
}
func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Request, manager *ssfreceiver.ConnectionManager, operation, traceID string) (string, string, string, bool) {
if manager == nil {
writeError(w, http.StatusNotFound, "security event connection does not exist", "security_event_connection_not_found")
return "", "", "", false
}
idempotencyKey, ok := requiredConnectionIdempotencyKey(w, r)
if !ok {
return "", "", "", false
}
requestHash := securityEventOperationHash(operation, normalizedConnectionETag(r.Header.Get("If-Match")))
if s.replaySecurityEventOperation(w, r, operation, idempotencyKey, requestHash) {
return "", "", "", false
}
connection, err := manager.Get(r.Context())
if err != nil {
s.writeSecurityEventConnectionError(w, r, manager, operation, traceID, err)
return "", "", "", false
}
if !matchConnectionVersion(w, r, connection.Version) {
return "", "", "", false
}
auditID, ok := s.requireSecurityEventConnectionAudit(w, r, operation, traceID, connection)
if !ok {
return "", "", "", false
}
return idempotencyKey, requestHash, auditID, true
}
func (s *Server) replaySecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, idempotencyKey, requestHash string) bool {
if s.store == nil {
return false
}
record, err := s.store.SecurityEventConnectionIdempotency(r.Context(), operation, idempotencyKey)
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
return false
}
if err != nil {
writeError(w, http.StatusServiceUnavailable, "security event idempotency state is unavailable", "security_event_state_unavailable")
return true
}
if record.RequestHash != requestHash {
writeError(w, http.StatusConflict, "Idempotency-Key was already used for a different request", "idempotency_key_reused")
return true
}
var payload securityEventConnectionResponse
if json.Unmarshal(record.Response, &payload) != nil {
writeError(w, http.StatusServiceUnavailable, "security event idempotency response is unavailable", "security_event_state_unavailable")
return true
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Idempotent-Replayed", "true")
w.Header().Set("ETag", fmt.Sprintf(`W/"%d"`, payload.Connection.Version))
if payload.AuditID != "" {
w.Header().Set("X-Audit-Id", payload.AuditID)
}
writeJSON(w, http.StatusAccepted, payload)
return true
}
func (s *Server) writeSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, idempotencyKey, requestHash, traceID, auditID string, connection ssfreceiver.ConnectionView) {
if auditID != "" {
w.Header().Set("X-Audit-Id", auditID)
}
payload := securityEventConnectionResponse{Connected: true, Connection: connection, TraceID: traceID, AuditID: auditID}
encoded, _ := json.Marshal(payload)
if s.store != nil {
if err := s.store.RecordSecurityEventConnectionIdempotency(r.Context(), operation, idempotencyKey, requestHash, encoded); err != nil && s.logger != nil {
s.logger.Error("security event idempotency result could not be recorded", "error_category", "idempotency_store_failed", "operation", operation)
}
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("ETag", fmt.Sprintf(`W/"%d"`, connection.Version))
writeJSON(w, http.StatusAccepted, payload)
}
func securityEventOperationHash(operation, canonicalRequest string) string {
digest := sha256.Sum256([]byte(operation + "\x00" + canonicalRequest))
return fmt.Sprintf("%x", digest[:])
}
func normalizedConnectionETag(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "W/")
return strings.Trim(value, `"`)
}
func matchConnectionVersion(w http.ResponseWriter, r *http.Request, expected int64) bool {
value := strings.TrimSpace(r.Header.Get("If-Match"))
value = strings.TrimPrefix(value, "W/")
value = strings.Trim(value, `"`)
version, err := strconv.ParseInt(value, 10, 64)
if err != nil {
writeError(w, http.StatusPreconditionRequired, "If-Match is required", "if_match_required")
return false
}
if version != expected {
writeError(w, http.StatusPreconditionFailed, "security event connection version changed", "version_conflict")
return false
}
return true
}
func (s *Server) writeSecurityEventConnectionError(w http.ResponseWriter, r *http.Request, manager *ssfreceiver.ConnectionManager, operation, traceID string, err error) {
status, message, code := securityEventConnectionErrorProjection(err)
connection := ssfreceiver.ConnectionView{}
if manager != nil {
connection, _ = manager.Get(r.Context())
}
errorCategory := code
if connection.LastErrorCategory != nil && *connection.LastErrorCategory != "" {
errorCategory = *connection.LastErrorCategory
}
if auditID := s.recordSecurityEventConnectionAudit(r, operation, "failure", errorCategory, traceID, connection); auditID != "" {
w.Header().Set("X-Audit-Id", auditID)
}
writeError(w, status, message, code)
}
func securityEventConnectionErrorProjection(err error) (int, string, string) {
switch {
case errors.Is(err, store.ErrSecurityEventConnectionNotFound):
return http.StatusNotFound, "security event connection does not exist", "security_event_connection_not_found"
case errors.Is(err, store.ErrSecurityEventConnectionConflict):
return http.StatusConflict, "security event connection conflicts with current state", "security_event_connection_conflict"
case strings.Contains(err.Error(), "configured"), strings.Contains(err.Error(), "invalid"), strings.Contains(err.Error(), "HTTPS"):
return http.StatusConflict, "security event connection prerequisites are incomplete", "security_event_prerequisite_missing"
default:
return http.StatusBadGateway, "authentication center security event service is unavailable", "security_event_transmitter_unavailable"
}
}
func ensureSecurityEventTraceID(w http.ResponseWriter, r *http.Request) string {
traceID := strings.TrimSpace(r.Header.Get("X-Trace-Id"))
if !validSecurityEventDiagnosticID(traceID) {
traceID = uuid.NewString()
}
w.Header().Set("X-Trace-Id", traceID)
return traceID
}
func validSecurityEventDiagnosticID(value string) bool {
if len(value) < 8 || len(value) > 128 {
return false
}
for _, character := range value {
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' ||
character >= '0' && character <= '9' || strings.ContainsRune("-_.", character) {
continue
}
return false
}
return true
}
func (s *Server) recordSecurityEventConnectionAudit(r *http.Request, operation, outcome, errorCategory, traceID string, connection ssfreceiver.ConnectionView) string {
if s.store == nil {
return ""
}
actor, _ := auth.UserFromContext(r.Context())
input := securityEventConnectionAuditInput(r, actor, operation, outcome, errorCategory, traceID, connection)
audit, err := s.store.RecordAuditLog(r.Context(), input)
if err != nil {
if s.logger != nil {
s.logger.WarnContext(r.Context(), "record security event connection audit failed", "operation", operation, "outcome", outcome, "error_category", "audit_store_failed", "trace_id", traceID)
}
return ""
}
return audit.ID
}
func (s *Server) requireSecurityEventConnectionAudit(w http.ResponseWriter, r *http.Request, operation, traceID string, connection ssfreceiver.ConnectionView) (string, bool) {
auditID := s.recordSecurityEventConnectionAudit(r, operation, "requested", "", traceID, connection)
if auditID == "" {
writeError(w, http.StatusServiceUnavailable, "security event audit is unavailable; operation was not executed", "security_event_audit_unavailable")
return "", false
}
w.Header().Set("X-Audit-Id", auditID)
return auditID, true
}
func securityEventConnectionAuditInput(r *http.Request, actor *auth.User, operation, outcome, errorCategory, traceID string, connection ssfreceiver.ConnectionView) store.AuditLogInput {
input := store.AuditLogInput{
Category: "identity", Action: "identity.security_event_connection." + operation,
TargetType: "security_event_connection", TargetID: firstNonEmptyText(connection.ConnectionID, "singleton"),
RequestIP: limitAuditText(requestIP(r), 128), UserAgent: limitAuditText(r.UserAgent(), 512),
AfterState: map[string]any{"lifecycleStatus": connection.LifecycleStatus, "healthMode": connection.HealthMode},
Metadata: map[string]any{"outcome": outcome, "errorCategory": errorCategory, "traceId": traceID},
}
if actor != nil {
input.ActorGatewayUserID = uuidText(firstNonEmptyText(actor.GatewayUserID, actor.ID))
input.ActorUserID, input.ActorUsername, input.ActorSource = actor.ID, actor.Username, actor.Source
input.ActorRoles = actor.Roles
}
return input
}

View File

@ -0,0 +1,88 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
)
func TestSecurityEventConnectionTraceAndAuditProjection(t *testing.T) {
request := httptest.NewRequest(http.MethodPut, "/connection", nil)
request.Header.Set("Authorization", "Bearer must-not-escape")
recorder := httptest.NewRecorder()
traceID := ensureSecurityEventTraceID(recorder, request)
if traceID == "" || recorder.Header().Get("X-Trace-Id") != traceID {
t.Fatalf("trace header=%q trace=%q", recorder.Header().Get("X-Trace-Id"), traceID)
}
actor := &auth.User{ID: "admin-id", Username: "admin", Source: "local", Roles: []string{"manager"}}
connection := ssfreceiver.ConnectionView{ConnectionID: "connection-id", LifecycleStatus: "error", HealthMode: "introspection_fallback"}
input := securityEventConnectionAuditInput(request, actor, "connect", "failure", "management_token_failed", traceID, connection)
payload, err := json.Marshal(input)
if err != nil {
t.Fatal(err)
}
if input.Action != "identity.security_event_connection.connect" || input.TargetID != "connection-id" || input.Metadata["traceId"] != traceID {
t.Fatalf("audit input=%#v", input)
}
for _, forbidden := range []string{"must-not-escape", "authorization_header", "push_bearer", "credential_ref"} {
if strings.Contains(strings.ToLower(string(payload)), forbidden) {
t.Fatalf("audit payload exposed forbidden field %q: %s", forbidden, payload)
}
}
}
func TestSecurityEventConnectionWriteHeaders(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/connection/verify", nil)
recorder := httptest.NewRecorder()
if _, ok := requiredConnectionIdempotencyKey(recorder, request); ok || recorder.Code != http.StatusBadRequest {
t.Fatalf("missing Idempotency-Key status=%d", recorder.Code)
}
request = httptest.NewRequest(http.MethodPut, "/connection", nil)
recorder = httptest.NewRecorder()
if matchConnectionVersion(recorder, request, 0) || recorder.Code != http.StatusPreconditionRequired {
t.Fatalf("initial connection without If-Match status=%d", recorder.Code)
}
request.Header.Set("If-Match", `W/"0"`)
recorder = httptest.NewRecorder()
if !matchConnectionVersion(recorder, request, 0) {
t.Fatalf("initial zero version was rejected: status=%d", recorder.Code)
}
request = httptest.NewRequest(http.MethodPost, "/connection/verify", nil)
request.Header.Set("If-Match", `W/"7"`)
recorder = httptest.NewRecorder()
if !matchConnectionVersion(recorder, request, 7) {
t.Fatalf("valid weak ETag was rejected: status=%d", recorder.Code)
}
request.Header.Set("If-Match", `"6"`)
recorder = httptest.NewRecorder()
if matchConnectionVersion(recorder, request, 7) || recorder.Code != http.StatusPreconditionFailed {
t.Fatalf("stale ETag status=%d", recorder.Code)
}
if normalizedConnectionETag(`W/"7"`) != "7" ||
securityEventOperationHash("verify", "7") == securityEventOperationHash("verify", "8") {
t.Fatal("security event idempotency request fingerprint is not stable")
}
}
func TestSecurityEventPrerequisitesNeverExposeSecrets(t *testing.T) {
server := &Server{identityTestRevision: identity.Revision{
Issuer: "https://auth.example/issuer/shared", TenantID: "stable-tenant",
MachineClientID: "gateway-machine", MachineCredentialRef: "identity-machine-test",
PublicBaseURL: "https://gateway.example",
}}
payload, err := json.Marshal(server.securityEventPrerequisites())
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(payload), "must-not-escape") || strings.Contains(strings.ToLower(string(payload)), "secret") {
t.Fatalf("secret escaped in prerequisites: %s", payload)
}
}

View File

@ -0,0 +1,30 @@
package httpapi
import "net/http"
// receiveSecurityEvent accepts an RFC 8417 Security Event Token over RFC 8935 Push.
//
// @Summary Receive an SSF Security Event Token
// @Description Optional endpoint. It validates a stream-specific Bearer before parsing and verifying an ES256 SET. A committed event and a duplicate jti both return an empty 202 response.
// @Tags Security Events
// @Accept application/secevent+jwt
// @Produce json
// @Param Authorization header string true "Bearer stream-specific-secret"
// @Param set body string true "Compact signed Security Event Token"
// @Success 202
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /api/v1/security-events/ssf [post]
func (s *Server) receiveSecurityEvent(w http.ResponseWriter, r *http.Request) {
receiver := s.securityEventReceiver
if s.identityRuntime != nil {
receiver = s.identityRuntime.SecurityEventReceiver()
}
if receiver == nil {
http.NotFound(w, r)
return
}
receiver.ServeHTTP(w, r)
}

View File

@ -0,0 +1,56 @@
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identityruntime"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
)
type preparedReceiverBuilder struct {
receiver http.Handler
}
func (builder *preparedReceiverBuilder) Build(context.Context, identity.Revision) (*identityruntime.Runtime, error) {
return &identityruntime.Runtime{}, nil
}
func (builder *preparedReceiverBuilder) PreparedSecurityEventReceiver() http.Handler {
return builder.receiver
}
func TestReceiveSecurityEventUsesPreparedReceiverBeforeFirstActivation(t *testing.T) {
called := false
builder := &preparedReceiverBuilder{receiver: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusAccepted)
})}
server := &Server{identityRuntime: identityruntime.NewManager(nil, builder)}
request := httptest.NewRequest(http.MethodPost, "/api/v1/security-events/ssf", nil)
response := httptest.NewRecorder()
server.receiveSecurityEvent(response, request)
if !called || response.Code != http.StatusAccepted {
t.Fatalf("prepared SSF receiver called=%t status=%d", called, response.Code)
}
}
func TestSecurityEventWriteAuditFailsClosedBeforeMutation(t *testing.T) {
server := &Server{}
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/security-events/connection/verify", nil)
recorder := httptest.NewRecorder()
if _, ok := server.requireSecurityEventConnectionAudit(
recorder, request, "verify", "trace-test", securityevents.ConnectionView{},
); ok {
t.Fatal("security event write unexpectedly continued without durable audit storage")
}
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("audit failure status=%d, want 503", recorder.Code)
}
}

View File

@ -11,23 +11,38 @@ import (
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identityruntime"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type Server struct {
ctx context.Context
cfg config.Config
store *store.Store
oidcUserResolver oidcUserResolver
auth *auth.Authenticator
oidcClient oidcPublicClient
oidcSessions oidcSessionManager
oidcSessionCipher *oidcsession.Cipher
runner *runner.Service
logger *slog.Logger
geminiUploadSessions sync.Map
ctx context.Context
cfg config.Config
store *store.Store
oidcUserResolver oidcUserResolver
auth *auth.Authenticator
oidcClient oidcPublicClient
oidcSessions oidcSessionManager
oidcSessionCipher *oidcsession.Cipher
runner *runner.Service
logger *slog.Logger
geminiUploadSessions sync.Map
securityEventReceiver http.Handler
securityEventManager *ssfreceiver.ConnectionManager
identityRuntime *identityruntime.Manager
identityPairing *identity.PairingService
identityManagementMu sync.Mutex
securityEventManagementMu sync.Mutex
identityPairingWorkers sync.Map
identityCleanupWorkers sync.Map
identityRestoredPairings sync.Map
identityTestRevision identity.Revision
identityTestCookieSecure bool
identityTestBrowserEnabled bool
}
type oidcPublicClient interface {
@ -60,55 +75,46 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
runner: runner.New(cfg, db, logger),
logger: logger,
}
server.auth.LegacyJWTEnabled = !cfg.OIDCEnabled || cfg.OIDCAcceptLegacyHS256
server.auth.ServerMainInternalKey = cfg.ServerMainInternalKey
server.auth.ServerMainInternalSecret = cfg.ServerMainInternalSecret
if cfg.OIDCEnabled {
verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{
Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience, TenantID: cfg.OIDCTenantID,
RolePrefix: cfg.OIDCRolePrefix, RequiredScopes: cfg.OIDCRequiredScopes,
JWKSCacheTTL: time.Duration(cfg.OIDCJWKSCacheTTLSeconds) * time.Second,
IntrospectionEnabled: cfg.OIDCIntrospectionEnabled,
IntrospectionClientID: cfg.OIDCIntrospectionClientID,
IntrospectionClientSecret: cfg.OIDCIntrospectionClientSecret,
})
if err != nil {
panic("invalid OIDC configuration: " + err.Error())
securityEventMetrics := &ssfreceiver.Metrics{}
secretStore, err := identitySecretStore(cfg)
if err != nil {
panic("invalid identity SecretStore: " + err.Error())
}
go ssfreceiver.RunSecurityEventRetirementWorker(ctx, db)
go ssfreceiver.RunIdentitySecretCleanupWorker(ctx, db, secretStore)
runtimeBuilder := identityruntime.NewRuntimeBuilder(ctx, db, secretStore, identityruntime.RuntimeBuilderConfig{
AppEnv: cfg.AppEnv, JWKSCacheTTL: 5 * time.Minute,
HeartbeatInterval: time.Duration(cfg.IdentitySecurityEventHeartbeatIntervalSeconds) * time.Second,
StaleAfter: time.Duration(cfg.IdentitySecurityEventStaleAfterSeconds) * time.Second,
ClockSkew: time.Duration(cfg.IdentitySecurityEventClockSkewSeconds) * time.Second,
}, securityEventMetrics)
server.identityRuntime = identityruntime.NewManager(db, runtimeBuilder)
if err := server.identityRuntime.LoadActive(ctx); err != nil && logger != nil {
logger.Error("load active identity runtime failed; local management login remains available", "error_category", "identity_runtime_load_failed")
}
server.identityPairing = identity.NewPairingService(db, secretStore, func(baseURL string) (identity.OnboardingRemote, error) {
return identity.NewOnboardingClient(baseURL, nil, cfg.AppEnv)
}, server.identityRuntime, cfg.AppEnv)
server.reconcileCanonicalIdentityPairing()
go server.runIdentityPairingCoordinator()
server.auth.OIDCVerifierProvider = func() *auth.OIDCVerifier {
if runtime := server.identityRuntime.Current(); runtime != nil {
return runtime.Verifier
}
server.auth.OIDCVerifier = verifier
if cfg.OIDCBrowserSessionEnabled {
key, err := cfg.OIDCSessionEncryptionKeyBytes()
if err != nil {
panic("invalid OIDC session configuration: " + err.Error())
}
cipher, err := oidcsession.NewCipher(key)
if err != nil {
panic("invalid OIDC session configuration: " + err.Error())
}
client, err := auth.NewOIDCPublicClient(auth.OIDCPublicClientConfig{
Issuer: cfg.OIDCIssuer, ClientID: cfg.OIDCClientID, RedirectURI: cfg.OIDCRedirectURI,
PostLogoutRedirectURI: cfg.OIDCPostLogoutRedirectURI,
Scopes: append([]string{"openid", "profile"}, cfg.OIDCRequiredScopes...),
})
if err != nil {
panic("invalid OIDC public client configuration: " + err.Error())
}
sessions, err := oidcsession.NewService(db, cipher, verifier, client, oidcsession.Config{
IdleTTL: time.Duration(cfg.OIDCSessionIdleTTLSeconds) * time.Second,
AbsoluteTTL: time.Duration(cfg.OIDCSessionAbsoluteTTLSeconds) * time.Second,
RefreshBefore: time.Duration(cfg.OIDCSessionRefreshBeforeSeconds) * time.Second,
})
if err != nil {
panic("invalid OIDC session configuration: " + err.Error())
}
server.oidcClient = client
server.oidcSessions = sessions
server.oidcSessionCipher = cipher
server.auth.OIDCSessionResolver = func(ctx context.Context, sessionID string) (*auth.User, error) {
user, resolveErr := sessions.Resolve(ctx, sessionID)
return user, oidcSessionRequestError(resolveErr)
}
return nil
}
server.auth.OIDCSessionResolverProvider = func(ctx context.Context, sessionID string) (*auth.User, error) {
runtime := server.identityRuntime.Current()
if runtime == nil || runtime.Sessions == nil {
return nil, auth.ErrUnauthorized
}
user, resolveErr := runtime.Sessions.Resolve(ctx, sessionID)
return user, oidcSessionRequestError(resolveErr)
}
server.auth.LegacyJWTEnabledProvider = func() bool {
return server.identityRuntime.LegacyJWTEnabled()
}
server.auth.LocalAPIKeyVerifier = db.VerifyLocalAPIKey
server.runner.StartAsyncQueueWorker(ctx)
@ -118,6 +124,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", server.health)
mux.HandleFunc("GET /readyz", server.ready)
mux.Handle("GET /metrics", securityEventMetrics.DynamicHandler(db))
mux.HandleFunc("GET /static/simulation/{asset}", serveSimulationAsset)
mux.HandleFunc("GET /static/generated/{asset}", server.serveGeneratedStaticAsset)
mux.HandleFunc("GET /static/uploaded/{asset}", server.serveUploadedStaticAsset)
@ -132,6 +139,8 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.HandleFunc("GET /api/v1/auth/oidc/callback", server.completeOIDCLogin)
mux.HandleFunc("POST /api/v1/auth/oidc/logout", server.logoutOIDCSession)
mux.HandleFunc("DELETE /api/v1/auth/oidc/session", server.deleteOIDCBrowserSession)
mux.HandleFunc("GET /api/v1/public/identity", server.getPublicIdentityConfiguration)
mux.HandleFunc("POST /api/v1/security-events/ssf", server.receiveSecurityEvent)
mux.Handle("GET /api/v1/me", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.me)))
mux.Handle("GET /api/v1/public/catalog/providers", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listCatalogProviders)))
mux.Handle("GET /api/v1/public/catalog/base-models", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listBaseModels)))
@ -201,6 +210,21 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("PATCH /api/admin/system/file-storage/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateFileStorageSettings)))
mux.Handle("GET /api/admin/system/client-customization/settings", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getClientCustomizationSettings)))
mux.Handle("PATCH /api/admin/system/client-customization/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateClientCustomizationSettings)))
mux.Handle("GET /api/admin/system/identity/configuration", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getIdentityConfiguration)))
mux.Handle("POST /api/admin/system/identity/pairings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.startIdentityPairing)))
mux.Handle("GET /api/admin/system/identity/pairings/{pairingID}", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getIdentityPairing)))
mux.Handle("POST /api/admin/system/identity/pairings/{pairingID}/cancel", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.cancelIdentityPairing)))
mux.Handle("POST /api/admin/system/identity/pairings/{pairingID}/retire-conflicting-security-event", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.retireIdentityPairingSecurityEventConflict)))
mux.Handle("PATCH /api/admin/system/identity/revisions/{revisionID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateIdentityDraftPolicy)))
mux.Handle("POST /api/admin/system/identity/revisions/{revisionID}/validate", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.validateIdentityRevision)))
mux.Handle("POST /api/admin/system/identity/revisions/{revisionID}/activate", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.activateIdentityRevision)))
mux.Handle("POST /api/admin/system/identity/revisions/{revisionID}/rollback", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.rollbackIdentityRevision)))
mux.Handle("POST /api/admin/system/identity/disable", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.disableIdentityConfiguration)))
mux.Handle("GET /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getSecurityEventConnection)))
mux.Handle("PUT /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.putSecurityEventConnection)))
mux.Handle("POST /api/admin/system/identity/security-events/connection/verify", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.verifySecurityEventConnection)))
mux.Handle("POST /api/admin/system/identity/security-events/connection/rotate-credential", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.rotateSecurityEventConnectionCredential)))
mux.Handle("DELETE /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteSecurityEventConnection)))
mux.Handle("GET /api/admin/system/file-storage/channels", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listFileStorageChannels)))
mux.Handle("POST /api/admin/system/file-storage/channels", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createFileStorageChannel)))
mux.Handle("PATCH /api/admin/system/file-storage/channels/{channelID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateFileStorageChannel)))
@ -278,6 +302,25 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
return server.recover(server.cors(server.protectOIDCSessionCookie(mux)))
}
func identitySecretStore(cfg config.Config) (ssfreceiver.SecretStore, error) {
switch strings.ToLower(strings.TrimSpace(cfg.IdentitySecretStore)) {
case "", "file":
directory := cfg.IdentitySecretDir
if directory == "" {
directory = ".local-secrets/identity"
}
return ssfreceiver.NewFileSecretStore(directory)
case "kubernetes":
return ssfreceiver.NewKubernetesSecretStore(ssfreceiver.KubernetesSecretStoreConfig{
Namespace: cfg.IdentityKubernetesNamespace, SecretName: cfg.IdentityKubernetesSecretName,
APIServer: cfg.IdentityKubernetesAPIServer, TokenFile: cfg.IdentityKubernetesTokenFile,
CAFile: cfg.IdentityKubernetesCAFile,
})
default:
return nil, errors.New("unsupported identity SecretStore")
}
}
func oidcSessionRequestError(err error) error {
switch {
case err == nil:
@ -288,6 +331,8 @@ func oidcSessionRequestError(err error) error {
return auth.NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SESSION_REFRESH_UNAVAILABLE", "认证中心暂时不可用,请稍后重试")
case errors.Is(err, oidcsession.ErrSessionStoreUnavailable):
return auth.NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SESSION_STORE_UNAVAILABLE", "登录会话存储暂时不可用")
case errors.Is(err, oidcsession.ErrSecurityStateUnavailable):
return auth.NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SECURITY_EVENT_STATE_UNAVAILABLE", "认证撤销状态暂时不可用")
case errors.Is(err, oidcsession.ErrGatewayUserDisabled):
return auth.NewRequestAuthError(http.StatusForbidden, "GATEWAY_USER_DISABLED", "该 Gateway 账号已停用,请联系管理员")
default:
@ -309,11 +354,11 @@ func (s *Server) requireAdmin(permission auth.Permission, next http.Handler) htt
func (s *Server) cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && originAllowed(origin, s.cfg.CORSAllowedOrigin) {
if origin != "" && s.corsOriginAllowed(origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Comfy-Api-Key, X-Goog-Api-Key, X-Goog-Upload-Protocol, X-Goog-Upload-Command, X-Goog-Upload-Header-Content-Length, X-Goog-Upload-Header-Content-Type, X-Goog-Upload-Offset, X-Async, X-EasyAI-Conversation-ID")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Idempotency-Key, If-Match, X-Comfy-Api-Key, X-Goog-Api-Key, X-Goog-Upload-Protocol, X-Goog-Upload-Command, X-Goog-Upload-Header-Content-Length, X-Goog-Upload-Header-Content-Type, X-Goog-Upload-Offset, X-Async, X-EasyAI-Conversation-ID")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
}
if r.Method == http.MethodOptions {
@ -324,10 +369,21 @@ func (s *Server) cors(next http.Handler) http.Handler {
})
}
func (s *Server) corsOriginAllowed(origin string) bool {
if originAllowed(origin, s.cfg.CORSAllowedOrigin) {
return true
}
if s.identityRuntime != nil {
return originMatchesBaseURL(origin, s.identityRuntime.TrustedWebBaseURL())
}
runtime := s.currentIdentityRuntime()
return runtime != nil && originMatchesBaseURL(origin, runtime.Revision.WebBaseURL)
}
func originAllowed(origin string, allowed string) bool {
for _, item := range strings.Split(allowed, ",") {
item = strings.TrimSpace(item)
if item == "*" || strings.EqualFold(origin, item) {
if item != "*" && strings.EqualFold(origin, item) {
return true
}
}

View File

@ -0,0 +1,298 @@
package identity
import (
"errors"
"net"
"net/url"
"strings"
"time"
"github.com/google/uuid"
)
var (
ErrRevisionNotFound = errors.New("identity configuration revision not found")
ErrRevisionConflict = errors.New("identity configuration revision conflicts with current state")
ErrBreakGlassRequired = errors.New("a local break-glass manager credential is required")
ErrLocalTenantInvalid = errors.New("local tenant mapping is invalid")
ErrActiveConfigurationHandoffRequired = errors.New("active identity configuration requires an explicit remote resource handoff before re-pairing")
ErrRollbackConfigurationHandoffRequired = errors.New("rollback requires a fresh remote resource handoff")
ErrSecurityEventRetirementPending = errors.New("active security event connection must retire before identity can be disabled")
)
type RevisionPolicy struct {
LocalTenantKey string `json:"localTenantKey"`
RolePrefix string `json:"rolePrefix"`
JITEnabled bool `json:"jitEnabled"`
LegacyJWTEnabled bool `json:"legacyJwtEnabled"`
SessionIdleSeconds int `json:"sessionIdleSeconds"`
SessionAbsoluteSeconds int `json:"sessionAbsoluteSeconds"`
SessionRefreshSeconds int `json:"sessionRefreshSeconds"`
}
func (policy RevisionPolicy) Validate() error {
if strings.TrimSpace(policy.LocalTenantKey) == "" || strings.TrimSpace(policy.RolePrefix) == "" {
return errors.New("local tenant mapping and role prefix are required")
}
if policy.SessionIdleSeconds <= 0 || policy.SessionAbsoluteSeconds <= policy.SessionIdleSeconds ||
policy.SessionRefreshSeconds <= 0 || policy.SessionRefreshSeconds >= policy.SessionIdleSeconds {
return errors.New("identity session policy is invalid")
}
return nil
}
type RevisionState string
const (
RevisionDraft RevisionState = "draft"
RevisionValidated RevisionState = "validated"
RevisionActive RevisionState = "active"
RevisionSuperseded RevisionState = "superseded"
RevisionFailed RevisionState = "failed"
)
type Revision struct {
ID string `json:"id"`
State RevisionState `json:"state"`
SchemaVersion int `json:"schemaVersion"`
AuthCenterURL string `json:"authCenterUrl"`
Issuer string `json:"issuer,omitempty"`
TenantID string `json:"tenantId,omitempty"`
ApplicationID string `json:"applicationId,omitempty"`
Audience string `json:"audience,omitempty"`
BrowserClientID string `json:"browserClientId,omitempty"`
MachineClientID string `json:"machineClientId,omitempty"`
Scopes []string `json:"scopes"`
Capabilities []string `json:"capabilities"`
RolePrefix string `json:"rolePrefix"`
LocalTenantKey string `json:"localTenantKey"`
PublicBaseURL string `json:"publicBaseUrl"`
WebBaseURL string `json:"webBaseUrl"`
JITEnabled bool `json:"jitEnabled"`
LegacyJWTEnabled bool `json:"legacyJwtEnabled"`
TokenIntrospection bool `json:"tokenIntrospection"`
SessionRevocation bool `json:"sessionRevocation"`
SecurityEventIssuer string `json:"securityEventIssuer,omitempty"`
SecurityEventConfigURL string `json:"securityEventConfigurationUrl,omitempty"`
SecurityEventAudience string `json:"securityEventAudience,omitempty"`
MachineCredentialRef string `json:"-"`
SessionEncryptionKeyRef string `json:"-"`
SessionIdleSeconds int `json:"sessionIdleSeconds"`
SessionAbsoluteSeconds int `json:"sessionAbsoluteSeconds"`
SessionRefreshSeconds int `json:"sessionRefreshSeconds"`
Version int64 `json:"version"`
LastErrorCategory string `json:"lastErrorCategory,omitempty"`
LastTraceID string `json:"lastTraceId,omitempty"`
LastAuditID string `json:"lastAuditId,omitempty"`
ValidatedAt *time.Time `json:"validatedAt,omitempty"`
ActivatedAt *time.Time `json:"activatedAt,omitempty"`
SupersededAt *time.Time `json:"supersededAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PairingInput struct {
AuthCenterURL string `json:"authCenterUrl"`
OnboardingCode string `json:"onboardingCode"`
PublicBaseURL string `json:"publicBaseUrl"`
WebBaseURL string `json:"webBaseUrl"`
LocalTenantKey string `json:"localTenantKey"`
LegacyJWTEnabled bool `json:"legacyJwtEnabled"`
}
type ConsumerMetadata struct {
PublicBaseURL string `json:"public_base_url"`
WebBaseURL string `json:"web_base_url"`
RedirectURIs []string `json:"redirect_uris"`
LogoutURIs []string `json:"logout_uris"`
ReceiverEndpoint string `json:"receiver_endpoint,omitempty"`
}
type ManifestApplication struct {
Manifest ManifestV1
MachineCredentialRef string
SessionEncryptionKeyRef string
TraceID string
AuditID string
AppEnv string
}
func NewDraft(input PairingInput, appEnv string) (Revision, error) {
if _, err := input.ConsumerMetadata(false, appEnv); err != nil {
return Revision{}, err
}
authCenter, _ := exactBaseURL(input.AuthCenterURL, appEnv)
publicBase, _ := exactBaseURL(input.PublicBaseURL, appEnv)
webBase, _ := exactBaseURL(input.WebBaseURL, appEnv)
return Revision{
ID: uuid.NewString(), State: RevisionDraft, SchemaVersion: 1,
AuthCenterURL: authCenter, RolePrefix: "gateway.", LocalTenantKey: strings.TrimSpace(input.LocalTenantKey),
PublicBaseURL: publicBase, WebBaseURL: webBase, JITEnabled: true, LegacyJWTEnabled: input.LegacyJWTEnabled,
Scopes: []string{}, Capabilities: []string{}, SessionIdleSeconds: 1800, SessionAbsoluteSeconds: 28800,
SessionRefreshSeconds: 60, Version: 1,
}, nil
}
func ApplyManifest(revision Revision, input ManifestApplication) (Revision, error) {
if revision.State != RevisionDraft {
return Revision{}, ErrRevisionConflict
}
if err := input.Manifest.Validate(input.AppEnv); err != nil {
return Revision{}, err
}
capabilities := make(map[string]bool, len(input.Manifest.Capabilities))
for _, capability := range input.Manifest.Capabilities {
capabilities[capability] = true
}
if capabilities["machine_to_machine"] && strings.TrimSpace(input.MachineCredentialRef) == "" {
return Revision{}, errors.New("machine credential reference is required")
}
if capabilities["oidc_login"] && strings.TrimSpace(input.SessionEncryptionKeyRef) == "" {
return Revision{}, errors.New("session encryption key reference is required")
}
revision.Issuer = strings.TrimRight(input.Manifest.Issuer, "/")
revision.TenantID = input.Manifest.TenantID
revision.ApplicationID = input.Manifest.ApplicationID
revision.Audience = input.Manifest.Audience
revision.Scopes = append([]string(nil), input.Manifest.Scopes...)
revision.Capabilities = append([]string(nil), input.Manifest.Capabilities...)
if input.Manifest.Clients.BrowserLogin != nil {
revision.BrowserClientID = input.Manifest.Clients.BrowserLogin.ClientID
}
if input.Manifest.Clients.MachineToMachine != nil {
revision.MachineClientID = input.Manifest.Clients.MachineToMachine.ClientID
}
revision.TokenIntrospection = capabilities["token_introspection"]
revision.SessionRevocation = capabilities["session_revocation"]
if input.Manifest.SecurityEvents != nil {
revision.SecurityEventIssuer = input.Manifest.SecurityEvents.TransmitterIssuer
revision.SecurityEventConfigURL = input.Manifest.SecurityEvents.ConfigurationEndpoint
revision.SecurityEventAudience = input.Manifest.SecurityEvents.Audience
}
revision.MachineCredentialRef = strings.TrimSpace(input.MachineCredentialRef)
revision.SessionEncryptionKeyRef = strings.TrimSpace(input.SessionEncryptionKeyRef)
revision.LastTraceID = strings.TrimSpace(input.TraceID)
revision.LastAuditID = strings.TrimSpace(input.AuditID)
return revision, nil
}
func CanTransition(from, to RevisionState) bool {
switch from {
case RevisionDraft:
return to == RevisionValidated || to == RevisionFailed
case RevisionValidated:
return to == RevisionActive || to == RevisionFailed
case RevisionActive:
return to == RevisionSuperseded
case RevisionSuperseded:
return to == RevisionValidated
default:
return false
}
}
func (input PairingInput) ConsumerMetadata(sessionRevocation bool, appEnv string) (ConsumerMetadata, error) {
authCenter, err := exactBaseURL(input.AuthCenterURL, appEnv)
if err != nil {
return ConsumerMetadata{}, errors.New("auth center URL is invalid")
}
_ = authCenter
publicBase, err := exactBaseURL(input.PublicBaseURL, appEnv)
if err != nil {
return ConsumerMetadata{}, errors.New("public base URL is invalid")
}
webBase, err := exactBaseURL(input.WebBaseURL, appEnv)
if err != nil {
return ConsumerMetadata{}, errors.New("web base URL is invalid")
}
if strings.TrimSpace(input.LocalTenantKey) == "" {
return ConsumerMetadata{}, errors.New("local tenant mapping is required")
}
metadata := ConsumerMetadata{
PublicBaseURL: publicBase, WebBaseURL: webBase,
RedirectURIs: []string{publicBase + "/api/v1/auth/oidc/callback"},
LogoutURIs: []string{webBase + "/"},
}
if sessionRevocation {
metadata.ReceiverEndpoint = publicBase + "/api/v1/security-events/ssf"
}
return metadata, nil
}
func exactBaseURL(raw, appEnv string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Opaque != "" || parsed.User != nil || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", errors.New("invalid public URL")
}
hostname := strings.ToLower(parsed.Hostname())
if parsed.Path != "" && parsed.Path != "/" {
return "", errors.New("base URL must not contain a path")
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "https" && !(scheme == "http" && isLocalIdentityEnvironment(appEnv) && isLoopbackHost(hostname)) {
return "", errors.New("public URL must use HTTPS")
}
port := parsed.Port()
if scheme == "https" && port == "443" || scheme == "http" && port == "80" {
port = ""
}
host := hostname
if strings.Contains(hostname, ":") {
host = "[" + hostname + "]"
}
if port != "" {
host = net.JoinHostPort(hostname, port)
}
return scheme + "://" + host, nil
}
func isLocalIdentityEnvironment(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "development", "dev", "local", "test":
return true
default:
return false
}
}
// ValidateRevisionURLs re-applies the deployment environment URL policy when
// constructing a Runtime. This protects against legacy or directly persisted
// revisions bypassing the pairing boundary.
func ValidateRevisionURLs(revision Revision, appEnv string) error {
urls := []struct {
label string
raw string
base bool
}{
{label: "auth center", raw: revision.AuthCenterURL, base: true},
{label: "issuer", raw: revision.Issuer},
{label: "public base", raw: revision.PublicBaseURL, base: true},
{label: "web base", raw: revision.WebBaseURL, base: true},
}
for _, candidate := range urls {
var err error
if candidate.base {
_, err = exactBaseURL(candidate.raw, appEnv)
} else {
err = validatePublicIdentityURL(candidate.raw, appEnv)
}
if err != nil {
return errors.New("identity " + candidate.label + " URL must use HTTPS in this environment")
}
}
if revision.SessionRevocation || revision.SecurityEventIssuer != "" || revision.SecurityEventConfigURL != "" {
if validatePublicIdentityURL(revision.SecurityEventIssuer, appEnv) != nil ||
validatePublicIdentityURL(revision.SecurityEventConfigURL, appEnv) != nil {
return errors.New("identity security event URLs must use HTTPS in this environment")
}
}
return nil
}
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}

View File

@ -0,0 +1,127 @@
package identity
import (
"encoding/json"
"strings"
"testing"
)
func TestRevisionStateTransitions(t *testing.T) {
tests := []struct {
from, to RevisionState
allowed bool
}{
{RevisionDraft, RevisionValidated, true},
{RevisionDraft, RevisionFailed, true},
{RevisionValidated, RevisionActive, true},
{RevisionValidated, RevisionFailed, true},
{RevisionActive, RevisionSuperseded, true},
{RevisionSuperseded, RevisionValidated, true},
{RevisionSuperseded, RevisionActive, false},
{RevisionDraft, RevisionActive, false},
{RevisionFailed, RevisionActive, false},
{RevisionActive, RevisionValidated, false},
}
for _, test := range tests {
if got := CanTransition(test.from, test.to); got != test.allowed {
t.Errorf("CanTransition(%q, %q)=%v, want %v", test.from, test.to, got, test.allowed)
}
}
}
func TestRevisionNeverSerializesSecretValues(t *testing.T) {
type secretFields interface {
MachineSecret() string
}
var _ = any((*Revision)(nil))
if _, exposesSecret := any((*Revision)(nil)).(secretFields); exposesSecret {
t.Fatal("Revision unexpectedly exposes a machine secret value")
}
revision := Revision{MachineCredentialRef: "identity-machine-example", SessionEncryptionKeyRef: "identity-session-example"}
if revision.MachineCredentialRef == "" || revision.SessionEncryptionKeyRef == "" {
t.Fatal("Revision must retain SecretStore references")
}
}
func TestValidatePairingInputDerivesExactGatewayURIs(t *testing.T) {
input := PairingInput{
AuthCenterURL: "https://auth.example.com", PublicBaseURL: "https://api.example.com",
WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
}
metadata, err := input.ConsumerMetadata(true, "production")
if err != nil {
t.Fatal(err)
}
if metadata.RedirectURIs[0] != "https://api.example.com/api/v1/auth/oidc/callback" ||
metadata.LogoutURIs[0] != "https://gateway.example.com/" ||
metadata.ReceiverEndpoint != "https://api.example.com/api/v1/security-events/ssf" {
t.Fatalf("unexpected derived metadata: %#v", metadata)
}
}
func TestValidatePairingInputRejectsRemoteHTTPAndURLCredentials(t *testing.T) {
for _, input := range []PairingInput{
{AuthCenterURL: "http://auth.example.com", PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default"},
{AuthCenterURL: "https://user:password@auth.example.com", PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default"},
} {
if _, err := input.ConsumerMetadata(false, "production"); err == nil {
t.Fatalf("unsafe pairing input accepted: %#v", input)
}
}
}
func TestNewDraftAppliesSessionDefaultsWithoutPersistingOnboardingCode(t *testing.T) {
draft, err := NewDraft(PairingInput{
AuthCenterURL: "https://auth.example.com", OnboardingCode: "onb1.must-never-be-persisted",
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com",
LocalTenantKey: "default", LegacyJWTEnabled: true,
}, "production")
if err != nil {
t.Fatal(err)
}
if draft.State != RevisionDraft || draft.SessionIdleSeconds != 1800 || draft.SessionAbsoluteSeconds != 28800 || draft.SessionRefreshSeconds != 60 {
t.Fatalf("unexpected draft defaults: %#v", draft)
}
payload, _ := json.Marshal(draft)
if strings.Contains(string(payload), "must-never-be-persisted") || strings.Contains(string(payload), "onboardingCode") {
t.Fatalf("draft exposed onboarding code: %s", payload)
}
}
func TestNewDraftAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) {
localInput := PairingInput{
AuthCenterURL: "http://localhost:18000", PublicBaseURL: "http://127.0.0.1:18089",
WebBaseURL: "http://localhost:5178", LocalTenantKey: "default",
}
secureInput := PairingInput{
AuthCenterURL: "https://auth.example.com", PublicBaseURL: "https://api.example.com",
WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
}
for _, test := range []struct {
name string
mutate func(*PairingInput)
}{
{name: "auth center", mutate: func(input *PairingInput) { input.AuthCenterURL = localInput.AuthCenterURL }},
{name: "public base", mutate: func(input *PairingInput) { input.PublicBaseURL = localInput.PublicBaseURL }},
{name: "web base", mutate: func(input *PairingInput) { input.WebBaseURL = localInput.WebBaseURL }},
} {
t.Run(test.name, func(t *testing.T) {
input := secureInput
test.mutate(&input)
for _, appEnv := range []string{"", "production", "staging"} {
if _, err := NewDraft(input, appEnv); err == nil {
t.Fatalf("%s accepted loopback HTTP %s URL", appEnv, test.name)
}
}
})
}
for _, appEnv := range []string{"local", "development", "dev", "test"} {
draft, err := NewDraft(localInput, appEnv)
if err != nil {
t.Fatalf("%s rejected loopback HTTP pairing URLs: %v", appEnv, err)
}
if draft.AuthCenterURL != localInput.AuthCenterURL || draft.PublicBaseURL != localInput.PublicBaseURL || draft.WebBaseURL != localInput.WebBaseURL {
t.Fatalf("%s changed normalized loopback URLs: %#v", appEnv, draft)
}
}
}

View File

@ -0,0 +1,304 @@
package identity
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
const maxOnboardingResponseBytes = 1 << 20
type ExchangeStatus string
const (
ExchangeMetadataPending ExchangeStatus = "metadata_pending"
ExchangePreparing ExchangeStatus = "preparing"
ExchangeReady ExchangeStatus = "ready"
ExchangeCredentialDelivered ExchangeStatus = "credential_delivered"
ExchangeCompleted ExchangeStatus = "completed"
ExchangeFailed ExchangeStatus = "failed"
ExchangeExpired ExchangeStatus = "expired"
)
type ClaimedExchange struct {
ExchangeID string `json:"exchange_id"`
ExchangeToken string `json:"exchange_token"`
ExpiresAt time.Time `json:"expires_at"`
Version int64 `json:"version"`
}
type Exchange struct {
ExchangeID string `json:"exchange_id"`
ApplicationID string `json:"application_id"`
Status ExchangeStatus `json:"status"`
Version int64 `json:"version"`
ExpiresAt time.Time `json:"expires_at"`
LastErrorCategory string `json:"last_error_category,omitempty"`
AuditID string `json:"audit_id,omitempty"`
}
type ManifestClient struct {
ClientID string `json:"client_id"`
}
type ManifestClients struct {
BrowserLogin *ManifestClient `json:"browser_login,omitempty"`
MachineToMachine *ManifestClient `json:"machine_to_machine,omitempty"`
}
type ManifestSecurityEvents struct {
TransmitterIssuer string `json:"transmitter_issuer"`
ConfigurationEndpoint string `json:"configuration_endpoint"`
Audience string `json:"audience"`
}
type ManifestV1 struct {
SchemaVersion int `json:"schema_version"`
Issuer string `json:"issuer"`
TenantID string `json:"tenant_id"`
ApplicationID string `json:"application_id"`
Capabilities []string `json:"capabilities"`
Audience string `json:"audience,omitempty"`
Scopes []string `json:"scopes"`
Clients ManifestClients `json:"clients"`
SecurityEvents *ManifestSecurityEvents `json:"security_events,omitempty"`
}
type MachineCredential struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
IssuedAt time.Time `json:"issued_at"`
}
type CredentialDelivery struct {
Manifest ManifestV1 `json:"manifest"`
MachineCredential *MachineCredential `json:"machine_credential,omitempty"`
Version int64 `json:"-"`
}
func (manifest ManifestV1) Validate(appEnv string) error {
if manifest.SchemaVersion != 1 || validatePublicIdentityURL(manifest.Issuer, appEnv) != nil {
return errors.New("application manifest identity metadata is invalid")
}
if _, err := uuid.Parse(manifest.TenantID); err != nil {
return errors.New("application manifest tenant is invalid")
}
if _, err := uuid.Parse(manifest.ApplicationID); err != nil {
return errors.New("application manifest application is invalid")
}
allowed := map[string]bool{"oidc_login": true, "api_access": true, "machine_to_machine": true, "token_introspection": true, "session_revocation": true}
capabilities := make(map[string]bool, len(manifest.Capabilities))
for _, capability := range manifest.Capabilities {
if !allowed[capability] || capabilities[capability] {
return errors.New("application manifest capabilities are invalid")
}
capabilities[capability] = true
}
if capabilities["session_revocation"] && !capabilities["token_introspection"] ||
capabilities["token_introspection"] && !capabilities["machine_to_machine"] {
return errors.New("application manifest capability dependencies are invalid")
}
if capabilities["oidc_login"] && (manifest.Clients.BrowserLogin == nil || strings.TrimSpace(manifest.Clients.BrowserLogin.ClientID) == "") {
return errors.New("application manifest browser client is missing")
}
if capabilities["machine_to_machine"] && (manifest.Clients.MachineToMachine == nil || strings.TrimSpace(manifest.Clients.MachineToMachine.ClientID) == "") {
return errors.New("application manifest machine client is missing")
}
if capabilities["api_access"] && strings.TrimSpace(manifest.Audience) == "" {
return errors.New("application manifest audience is missing")
}
if capabilities["session_revocation"] && manifest.SecurityEvents == nil {
return errors.New("application manifest security event metadata is invalid")
}
if manifest.SecurityEvents != nil {
if validatePublicIdentityURL(manifest.SecurityEvents.TransmitterIssuer, appEnv) != nil ||
validatePublicIdentityURL(manifest.SecurityEvents.ConfigurationEndpoint, appEnv) != nil || strings.TrimSpace(manifest.SecurityEvents.Audience) == "" {
return errors.New("application manifest security event metadata is invalid")
}
}
seenScopes := make(map[string]bool, len(manifest.Scopes))
for _, scope := range manifest.Scopes {
scope = strings.TrimSpace(scope)
if scope == "" || seenScopes[scope] {
return errors.New("application manifest scopes are invalid")
}
seenScopes[scope] = true
}
return nil
}
func validatePublicIdentityURL(raw, appEnv string) error {
_, err := exactBaseURL(raw, appEnv)
if err == nil {
return nil
}
// Discovery endpoints may contain a path, but must retain the same URL
// safety constraints as a base URL.
parsed, parseErr := http.NewRequest(http.MethodGet, strings.TrimSpace(raw), nil)
if parseErr != nil || parsed.URL.User != nil || parsed.URL.Host == "" || parsed.URL.RawQuery != "" || parsed.URL.Fragment != "" {
return errors.New("identity URL is invalid")
}
scheme := strings.ToLower(parsed.URL.Scheme)
if scheme == "https" || scheme == "http" && isLocalIdentityEnvironment(appEnv) && isLoopbackHost(strings.ToLower(parsed.URL.Hostname())) {
return nil
}
return errors.New("identity URL must use HTTPS")
}
type OnboardingClient struct {
baseURL string
client *http.Client
appEnv string
}
func NewOnboardingClient(baseURL string, base *http.Client, appEnv string) (*OnboardingClient, error) {
normalized, err := exactBaseURL(baseURL, appEnv)
if err != nil {
return nil, errors.New("auth center URL is invalid")
}
if base == nil {
base = &http.Client{Timeout: 10 * time.Second}
}
client := *base
if client.Timeout <= 0 {
client.Timeout = 10 * time.Second
}
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
return &OnboardingClient{baseURL: normalized, client: &client, appEnv: appEnv}, nil
}
func (client *OnboardingClient) Claim(ctx context.Context, code string) (ClaimedExchange, error) {
var output ClaimedExchange
status, _, err := client.request(ctx, http.MethodPost, "/api/v1/onboarding-exchanges", "", "", 0,
map[string]string{"onboarding_code": strings.TrimSpace(code)}, &output)
if err != nil || status != http.StatusCreated || output.Version < 1 || output.ExchangeID == "" || output.ExchangeToken == "" {
return ClaimedExchange{}, onboardingProtocolError(err)
}
return output, nil
}
func (client *OnboardingClient) SubmitMetadata(ctx context.Context, claimed ClaimedExchange, metadata ConsumerMetadata, idempotencyKey string) (Exchange, error) {
var output Exchange
status, _, err := client.request(ctx, http.MethodPut, "/api/v1/onboarding-exchanges/"+claimed.ExchangeID+"/metadata",
claimed.ExchangeToken, idempotencyKey, claimed.Version, metadata, &output)
if err != nil || status != http.StatusAccepted || output.Version < 1 {
return Exchange{}, onboardingProtocolError(err)
}
return output, nil
}
func (client *OnboardingClient) Get(ctx context.Context, exchangeID, exchangeToken string) (Exchange, error) {
var output Exchange
status, _, err := client.request(ctx, http.MethodGet, "/api/v1/onboarding-exchanges/"+exchangeID, exchangeToken, "", 0, nil, &output)
if err != nil || status != http.StatusOK || output.Version < 1 {
return Exchange{}, onboardingProtocolError(err)
}
return output, nil
}
func (client *OnboardingClient) DeliverCredential(ctx context.Context, exchange Exchange, exchangeToken, idempotencyKey string) (CredentialDelivery, error) {
var output CredentialDelivery
status, etag, err := client.request(ctx, http.MethodPost, "/api/v1/onboarding-exchanges/"+exchange.ExchangeID+"/credential-deliveries",
exchangeToken, idempotencyKey, exchange.Version, nil, &output)
if err != nil || status != http.StatusOK {
return CredentialDelivery{}, onboardingProtocolError(err)
}
if err := output.Manifest.Validate(client.appEnv); err != nil {
return CredentialDelivery{}, err
}
output.Version, err = parseWeakETag(etag)
if err != nil {
return CredentialDelivery{}, errors.New("onboarding response ETag is invalid")
}
return output, nil
}
func (client *OnboardingClient) Complete(ctx context.Context, exchangeID, exchangeToken, idempotencyKey string, version int64) error {
status, _, err := client.request(ctx, http.MethodPost, "/api/v1/onboarding-exchanges/"+exchangeID+"/completion",
exchangeToken, idempotencyKey, version, nil, nil)
if err != nil || status != http.StatusNoContent {
return onboardingProtocolError(err)
}
return nil
}
func (client *OnboardingClient) request(ctx context.Context, method, path, token, idempotencyKey string, version int64, body, output any) (int, string, error) {
var reader io.Reader
if body != nil {
payload, err := json.Marshal(body)
if err != nil {
return 0, "", err
}
reader = bytes.NewReader(payload)
}
request, err := http.NewRequestWithContext(ctx, method, client.baseURL+path, reader)
if err != nil {
return 0, "", err
}
request.Header.Set("Accept", "application/json")
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
if idempotencyKey != "" {
request.Header.Set("Idempotency-Key", idempotencyKey)
}
if version > 0 {
request.Header.Set("If-Match", fmt.Sprintf(`W/"%d"`, version))
}
response, err := client.client.Do(request)
if err != nil {
return 0, "", errors.New("onboarding endpoint request failed")
}
defer response.Body.Close()
if response.StatusCode >= 300 && response.StatusCode < 400 {
return response.StatusCode, "", errors.New("onboarding endpoint redirect is forbidden")
}
if output == nil || response.StatusCode == http.StatusNoContent {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOnboardingResponseBytes))
return response.StatusCode, response.Header.Get("ETag"), nil
}
payload, err := io.ReadAll(io.LimitReader(response.Body, maxOnboardingResponseBytes+1))
if err != nil || len(payload) > maxOnboardingResponseBytes {
return response.StatusCode, "", errors.New("onboarding endpoint response is invalid")
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return response.StatusCode, "", errors.New("onboarding endpoint rejected the request")
}
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(output); err != nil {
return response.StatusCode, "", errors.New("onboarding endpoint response is invalid")
}
return response.StatusCode, response.Header.Get("ETag"), nil
}
func onboardingProtocolError(cause error) error {
if cause == nil {
return errors.New("onboarding endpoint returned an unexpected status")
}
return cause
}
func parseWeakETag(value string) (int64, error) {
value = strings.TrimSpace(value)
if !strings.HasPrefix(value, `W/"`) || !strings.HasSuffix(value, `"`) {
return 0, errors.New("invalid ETag")
}
version, err := strconv.ParseInt(strings.TrimSuffix(strings.TrimPrefix(value, `W/"`), `"`), 10, 64)
if err != nil || version < 1 {
return 0, errors.New("invalid ETag")
}
return version, nil
}

View File

@ -0,0 +1,155 @@
package identity
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestOnboardingClientKeepsCodeAndExchangeTokenOutOfURLs(t *testing.T) {
const code = "onb1.aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.abcdefghijklmnopqrstuvwxyzABCDEFGH"
const token = "ex1.bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb.abcdefghijklmnopqrstuvwxyzABCDEFGH"
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.String(), code) || strings.Contains(r.URL.String(), token) {
t.Fatal("onboarding credential leaked into URL")
}
switch {
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/onboarding-exchanges":
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if body["onboarding_code"] != code || r.Header.Get("Authorization") != "" {
t.Fatalf("unexpected claim request: body=%#v authorization=%q", body, r.Header.Get("Authorization"))
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"exchange_id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","exchange_token":"` + token + `","expires_at":"2026-07-17T12:30:00Z","version":1}`))
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/onboarding-exchanges/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/metadata":
if r.Header.Get("Authorization") != "Bearer "+token || r.Header.Get("If-Match") != `W/"1"` || len(r.Header.Get("Idempotency-Key")) < 16 {
t.Fatalf("unexpected exchange headers: %#v", r.Header)
}
w.Header().Set("ETag", `W/"2"`)
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(`{"exchange_id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","application_id":"cccccccc-cccc-cccc-cccc-cccccccccccc","status":"preparing","version":2,"expires_at":"2026-07-17T12:30:00Z"}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client, err := NewOnboardingClient(server.URL, server.Client(), "production")
if err != nil {
t.Fatal(err)
}
claimed, err := client.Claim(context.Background(), code)
if err != nil {
t.Fatal(err)
}
metadata := ConsumerMetadata{PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", RedirectURIs: []string{"https://api.example.com/api/v1/auth/oidc/callback"}, LogoutURIs: []string{"https://gateway.example.com/"}}
view, err := client.SubmitMetadata(context.Background(), claimed, metadata, "pairing-metadata-123456")
if err != nil || view.Status != ExchangePreparing || view.Version != 2 {
t.Fatalf("view=%#v err=%v", view, err)
}
}
func TestManifestV1ValidationRequiresStableFieldsAndCapabilityDependencies(t *testing.T) {
valid := ManifestV1{
SchemaVersion: 1, Issuer: "https://auth.example.com/issuer/shared",
TenantID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", ApplicationID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
Capabilities: []string{"oidc_login", "api_access", "machine_to_machine", "token_introspection", "session_revocation"},
Audience: "urn:easyai:resource:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", Scopes: []string{"openid", "gateway.access"},
Clients: ManifestClients{BrowserLogin: &ManifestClient{ClientID: "browser"}, MachineToMachine: &ManifestClient{ClientID: "service"}},
SecurityEvents: &ManifestSecurityEvents{TransmitterIssuer: "https://auth.example.com/ssf", ConfigurationEndpoint: "https://auth.example.com/.well-known/ssf-configuration/ssf", Audience: "urn:easyai:ssf:receiver:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"},
}
if err := valid.Validate("production"); err != nil {
t.Fatal(err)
}
invalid := valid
invalid.Capabilities = []string{"session_revocation"}
if err := invalid.Validate("production"); err == nil {
t.Fatal("manifest with missing capability dependencies was accepted")
}
invalid = valid
invalid.SchemaVersion = 2
if err := invalid.Validate("production"); err == nil {
t.Fatal("unsupported manifest schema was accepted")
}
}
func TestOnboardingClientRejectsRedirects(t *testing.T) {
target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "should not be reached", http.StatusTeapot)
}))
defer target.Close()
redirect := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
}))
defer redirect.Close()
client, err := NewOnboardingClient(redirect.URL, redirect.Client(), "production")
if err != nil {
t.Fatal(err)
}
if _, err := client.Claim(context.Background(), "onb1.aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.abcdefghijklmnopqrstuvwxyzABCDEFGH"); err == nil {
t.Fatal("redirecting onboarding endpoint was accepted")
}
}
func TestManifestAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) {
manifest := ManifestV1{
SchemaVersion: 1, Issuer: "https://auth.example.com/issuer/easyai",
TenantID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", ApplicationID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
Capabilities: []string{"machine_to_machine", "token_introspection", "session_revocation"}, Scopes: []string{"gateway.access"},
Clients: ManifestClients{MachineToMachine: &ManifestClient{ClientID: "service"}},
SecurityEvents: &ManifestSecurityEvents{
TransmitterIssuer: "https://auth.example.com/ssf", ConfigurationEndpoint: "https://auth.example.com/.well-known/ssf-configuration/ssf",
Audience: "urn:easyai:ssf:receiver:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
},
}
for _, test := range []struct {
name string
mutate func(*ManifestV1)
}{
{name: "OIDC issuer", mutate: func(value *ManifestV1) { value.Issuer = "http://localhost:18003/issuer/easyai" }},
{name: "SSF issuer", mutate: func(value *ManifestV1) { value.SecurityEvents.TransmitterIssuer = "http://127.0.0.1:18004/ssf" }},
{name: "SSF configuration", mutate: func(value *ManifestV1) {
value.SecurityEvents.ConfigurationEndpoint = "http://127.0.0.1:18004/.well-known/ssf-configuration/ssf"
}},
} {
t.Run(test.name, func(t *testing.T) {
candidate := manifest
securityEvents := *manifest.SecurityEvents
candidate.SecurityEvents = &securityEvents
test.mutate(&candidate)
if err := candidate.Validate("production"); err == nil {
t.Fatalf("production accepted loopback HTTP %s URL", test.name)
}
})
}
localManifest := manifest
localSecurityEvents := *manifest.SecurityEvents
localManifest.SecurityEvents = &localSecurityEvents
localManifest.Issuer = "http://localhost:18003/issuer/easyai"
localManifest.SecurityEvents.TransmitterIssuer = "http://127.0.0.1:18004/ssf"
localManifest.SecurityEvents.ConfigurationEndpoint = "http://127.0.0.1:18004/.well-known/ssf-configuration/ssf"
if err := localManifest.Validate("test"); err != nil {
t.Fatalf("test environment rejected loopback HTTP manifest URLs: %v", err)
}
localManifest.Capabilities = []string{"machine_to_machine"}
localManifest.Issuer = manifest.Issuer
if err := localManifest.Validate("production"); err == nil {
t.Fatal("production accepted optional loopback HTTP security event metadata")
}
}
func TestOnboardingClientAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer server.Close()
if _, err := NewOnboardingClient(server.URL, server.Client(), "production"); err == nil {
t.Fatal("production accepted loopback HTTP Auth Center URL")
}
if _, err := NewOnboardingClient(server.URL, server.Client(), "development"); err != nil {
t.Fatalf("development rejected loopback HTTP Auth Center URL: %v", err)
}
}

View File

@ -0,0 +1,632 @@
package identity
import (
"context"
"crypto/rand"
"errors"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
type PairingStatus string
const (
PairingMetadataPending PairingStatus = "metadata_pending"
PairingPreparing PairingStatus = "preparing"
PairingReady PairingStatus = "ready"
PairingCredentialsSaved PairingStatus = "credentials_saved"
PairingCompleted PairingStatus = "completed"
PairingFailed PairingStatus = "failed"
PairingExpired PairingStatus = "expired"
PairingCancelled PairingStatus = "cancelled"
)
type PairingCleanupStatus string
const (
PairingCleanupNone PairingCleanupStatus = "none"
PairingCleanupPending PairingCleanupStatus = "pending"
PairingCleanupCompleted PairingCleanupStatus = "completed"
)
var (
ErrPairingInProgress = errors.New("an identity pairing is already in progress")
ErrPairingNotCancellable = errors.New("identity pairing cannot be cancelled")
ErrPairingConflictNotResolvable = errors.New("identity pairing has no resolvable security event conflict")
)
const (
identityPairingStartReservationTTL = 2 * time.Minute
identityPairingStartReleaseTimeout = 5 * time.Second
)
type PairingExchange struct {
ID string `json:"id"`
RevisionID string `json:"revisionId"`
RemoteExchangeID string `json:"remoteExchangeId"`
ExchangeTokenRef string `json:"-"`
Status PairingStatus `json:"status"`
CleanupStatus PairingCleanupStatus `json:"cleanupStatus"`
RemoteVersion int64 `json:"remoteVersion"`
ExpiresAt time.Time `json:"expiresAt"`
Version int64 `json:"version"`
LastErrorCategory string `json:"lastErrorCategory,omitempty"`
AuthCenterAuditID string `json:"authCenterAuditId,omitempty"`
LastTraceID string `json:"lastTraceId,omitempty"`
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
CleanupCompletedAt *time.Time `json:"cleanupCompletedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PairingExchangeUpdate struct {
Status PairingStatus
RemoteVersion int64
LastErrorCategory string
AuthCenterAuditID string
}
type PairingRepository interface {
ReserveIdentityPairingStart(context.Context, string, time.Time) error
ReleaseIdentityPairingStart(context.Context, string) error
CommitIdentityPairingStart(context.Context, Revision, PairingExchange) (PairingExchange, error)
IdentityPairingStartBlocked(context.Context) (bool, error)
IdentityConfigurationRevision(context.Context, string) (Revision, error)
ApplyIdentityManifest(context.Context, string, int64, ManifestApplication) (Revision, error)
IdentityPairingExchange(context.Context, string) (PairingExchange, error)
UpdateIdentityPairingExchange(context.Context, string, int64, PairingExchangeUpdate) (PairingExchange, error)
RecordIdentityPairingRetryFailure(context.Context, string, PairingStatus, string) (PairingExchange, error)
CancelIdentityPairingExchange(context.Context, string, int64, string, string) (PairingExchange, error)
CompleteIdentityPairingCleanup(context.Context, string, int64) (PairingExchange, error)
RecordIdentityPairingCleanupFailure(context.Context, string, string) (PairingExchange, error)
QueueIdentitySecretCleanup(context.Context, string, time.Time) error
RemovePendingIdentitySecretCleanup(context.Context, string) (bool, error)
}
type PairingSecretStore interface {
Put(context.Context, string, []byte) error
Get(context.Context, string) ([]byte, error)
Delete(context.Context, string) error
}
type OnboardingRemote interface {
Claim(context.Context, string) (ClaimedExchange, error)
SubmitMetadata(context.Context, ClaimedExchange, ConsumerMetadata, string) (Exchange, error)
Get(context.Context, string, string) (Exchange, error)
DeliverCredential(context.Context, Exchange, string, string) (CredentialDelivery, error)
Complete(context.Context, string, string, string, int64) error
}
type SecurityEventPreparer interface {
PrepareSecurityEvents(context.Context, Revision, []byte) error
CleanupPreparedSecurityEvents(context.Context, Revision) error
}
type SecurityEventConflictResolver interface {
RetireConflictingSecurityEvents(context.Context, Revision) error
}
type PairingService struct {
repository PairingRepository
secrets PairingSecretStore
remote func(string) (OnboardingRemote, error)
security SecurityEventPreparer
appEnv string
operations sync.Map
}
func NewPairingService(repository PairingRepository, secrets PairingSecretStore, remote func(string) (OnboardingRemote, error), security SecurityEventPreparer, appEnvs ...string) *PairingService {
appEnv := "production"
if len(appEnvs) > 0 {
appEnv = appEnvs[0]
}
return &PairingService{repository: repository, secrets: secrets, remote: remote, security: security, appEnv: appEnv}
}
func (service *PairingService) Start(ctx context.Context, input PairingInput, traceID string) (PairingExchange, error) {
draft, err := NewDraft(input, service.appEnv)
if err != nil {
return PairingExchange{}, err
}
draft.LastTraceID = strings.TrimSpace(traceID)
pairingID := uuid.NewString()
tokenReference := "identity-exchange-" + pairingID
if err := service.repository.ReserveIdentityPairingStart(ctx, pairingID, time.Now().UTC().Add(identityPairingStartReservationTTL)); err != nil {
return PairingExchange{}, err
}
committed := false
defer func() {
if !committed {
releaseContext, cancel := context.WithTimeout(context.Background(), identityPairingStartReleaseTimeout)
defer cancel()
_ = service.repository.ReleaseIdentityPairingStart(releaseContext, pairingID)
}
}()
remote, err := service.remote(draft.AuthCenterURL)
if err != nil {
return PairingExchange{}, err
}
claimed, err := remote.Claim(ctx, strings.TrimSpace(input.OnboardingCode))
if err != nil {
return PairingExchange{}, err
}
token := []byte(claimed.ExchangeToken)
claimed.ExchangeToken = ""
if err := service.stageIdentitySecret(ctx, tokenReference, token); err != nil {
clear(token)
return PairingExchange{}, err
}
clear(token)
pairing := PairingExchange{
ID: pairingID, RevisionID: draft.ID, RemoteExchangeID: claimed.ExchangeID, ExchangeTokenRef: tokenReference,
Status: PairingMetadataPending, CleanupStatus: PairingCleanupNone, RemoteVersion: claimed.Version, ExpiresAt: claimed.ExpiresAt,
Version: 1, LastTraceID: strings.TrimSpace(traceID),
}
pairing, err = service.repository.CommitIdentityPairingStart(ctx, draft, pairing)
if err != nil {
// Commit may have succeeded even when its response was lost. The
// staging row is the source of truth: rollback leaves it queued, while
// a successful commit atomically adopts it. Re-queueing here could
// delete a Secret that the new Pairing already references.
return PairingExchange{}, err
}
committed = true
return pairing, nil
}
func (service *PairingService) Cancel(ctx context.Context, pairingID string, expectedVersion int64, traceID, auditID string) (PairingExchange, error) {
return service.repository.CancelIdentityPairingExchange(ctx, pairingID, expectedVersion, strings.TrimSpace(traceID), strings.TrimSpace(auditID))
}
func (service *PairingService) RetireConflictingSecurityEvents(ctx context.Context, pairingID string, expectedVersion int64) (PairingExchange, error) {
unlock := service.lockPairingOperation(pairingID)
defer unlock()
pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID)
if err != nil {
return PairingExchange{}, err
}
if pairing.Version != expectedVersion {
return pairing, ErrRevisionConflict
}
if pairing.Status != PairingCredentialsSaved || pairing.CleanupStatus != PairingCleanupNone ||
pairing.LastErrorCategory != "security_event_connection_conflict" {
return pairing, ErrPairingConflictNotResolvable
}
revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
if err != nil {
return pairing, err
}
if revision.State != RevisionDraft || !revision.SessionRevocation {
return pairing, ErrPairingConflictNotResolvable
}
resolver, ok := service.security.(SecurityEventConflictResolver)
if !ok {
return pairing, errors.New("security event conflict recovery is unavailable")
}
if err := resolver.RetireConflictingSecurityEvents(ctx, revision); err != nil {
return pairing, err
}
return service.repository.RecordIdentityPairingRetryFailure(ctx, pairing.ID, pairing.Status, "security_event_retirement_pending")
}
// RestoreCompletedSecurityEvents reconstructs the prepared Receiver after a
// process restart while a completed Revision is still awaiting validation or
// activation. The machine Secret is read only from SecretStore and cleared
// immediately after use.
func (service *PairingService) RestoreCompletedSecurityEvents(ctx context.Context, pairingID string) error {
unlock := service.lockPairingOperation(pairingID)
defer unlock()
pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID)
if err != nil {
return err
}
if pairing.Status != PairingCompleted {
return ErrRevisionConflict
}
revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
if err != nil {
return err
}
if revision.State == RevisionActive {
return nil
}
if revision.State != RevisionDraft && revision.State != RevisionValidated {
return ErrRevisionConflict
}
if !revision.SessionRevocation {
return nil
}
if service.security == nil || revision.MachineCredentialRef == "" {
return errors.New("completed security event configuration is unavailable")
}
secret, err := service.secrets.Get(ctx, revision.MachineCredentialRef)
if err != nil {
return err
}
defer clear(secret)
if err := service.security.PrepareSecurityEvents(ctx, revision, secret); err != nil {
return err
}
currentRevision, revisionErr := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
if revisionErr != nil {
return revisionErr
}
if currentRevision.State == RevisionActive {
return nil
}
currentPairing, pairingErr := service.repository.IdentityPairingExchange(ctx, pairingID)
if pairingErr != nil {
return pairingErr
}
if currentPairing.Status == PairingCompleted &&
(currentRevision.State == RevisionDraft || currentRevision.State == RevisionValidated) {
return nil
}
if cleanupErr := service.security.CleanupPreparedSecurityEvents(ctx, currentRevision); cleanupErr != nil {
return cleanupErr
}
return ErrRevisionConflict
}
func (service *PairingService) Cleanup(ctx context.Context, pairingID string) (PairingExchange, error) {
unlock := service.lockPairingOperation(pairingID)
defer unlock()
pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID)
if err != nil {
return PairingExchange{}, err
}
if pairing.Status != PairingCancelled {
return pairing, ErrPairingNotCancellable
}
if pairing.CleanupStatus == PairingCleanupCompleted {
return pairing, nil
}
if pairing.CleanupStatus != PairingCleanupPending {
return pairing, ErrRevisionConflict
}
revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
if err != nil {
return service.recordCleanupFailure(ctx, pairing, "cleanup_revision_unavailable", err)
}
if revision.SessionRevocation {
if service.security == nil {
return service.recordCleanupFailure(ctx, pairing, "cleanup_security_event_unavailable", errors.New("security event cleanup is unavailable"))
}
if err := service.security.CleanupPreparedSecurityEvents(ctx, revision); err != nil {
return service.recordCleanupFailure(ctx, pairing, pairingCleanupFailureCategory(err), err)
}
}
for _, reference := range []string{pairing.ExchangeTokenRef, revision.MachineCredentialRef, revision.SessionEncryptionKeyRef} {
if reference == "" {
continue
}
if err := service.secrets.Delete(ctx, reference); err != nil {
return service.recordCleanupFailure(ctx, pairing, "cleanup_secret_store_failed", err)
}
}
completed, err := service.repository.CompleteIdentityPairingCleanup(ctx, pairing.ID, pairing.Version)
if err != nil {
return service.recordCleanupFailure(ctx, pairing, "cleanup_finalize_failed", err)
}
return completed, nil
}
func (service *PairingService) recordCleanupFailure(ctx context.Context, pairing PairingExchange, category string, cause error) (PairingExchange, error) {
if errors.Is(cause, context.Canceled) || errors.Is(cause, context.DeadlineExceeded) {
return pairing, cause
}
recorded, err := service.repository.RecordIdentityPairingCleanupFailure(ctx, pairing.ID, category)
if err != nil {
return pairing, cause
}
return recorded, cause
}
func (service *PairingService) Continue(ctx context.Context, pairingID string) (PairingExchange, error) {
unlock := service.lockPairingOperation(pairingID)
defer unlock()
pairing, err := service.continuePairing(ctx, pairingID)
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return pairing, err
}
current, currentErr := service.repository.IdentityPairingExchange(ctx, pairingID)
if currentErr != nil || !isPairingInProgress(current.Status) {
return pairing, err
}
category := pairingRetryFailureCategory(current.Status, err)
recorded, recordErr := service.repository.RecordIdentityPairingRetryFailure(ctx, current.ID, current.Status, category)
if recordErr != nil {
return current, err
}
return recorded, err
}
func (service *PairingService) lockPairingOperation(pairingID string) func() {
value, _ := service.operations.LoadOrStore(pairingID, &sync.Mutex{})
mutex := value.(*sync.Mutex)
mutex.Lock()
return mutex.Unlock
}
func (service *PairingService) continuePairing(ctx context.Context, pairingID string) (PairingExchange, error) {
for step := 0; step < 6; step++ {
pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID)
if err != nil {
return PairingExchange{}, err
}
if pairing.Status == PairingCompleted || pairing.Status == PairingFailed || pairing.Status == PairingExpired || pairing.Status == PairingCancelled {
return pairing, nil
}
if !pairing.ExpiresAt.After(time.Now()) {
expired, updateErr := service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{
Status: PairingExpired, RemoteVersion: pairing.RemoteVersion, LastErrorCategory: "exchange_expired",
})
if updateErr == nil {
service.deleteRetiredIdentitySecret(ctx, pairing.ExchangeTokenRef)
}
return expired, updateErr
}
revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
if err != nil {
return PairingExchange{}, err
}
token, err := service.secrets.Get(ctx, pairing.ExchangeTokenRef)
if err != nil {
return PairingExchange{}, err
}
remote, err := service.remote(revision.AuthCenterURL)
if err != nil {
clear(token)
return PairingExchange{}, err
}
switch pairing.Status {
case PairingMetadataPending:
metadata, metadataErr := PairingInput{
AuthCenterURL: revision.AuthCenterURL, PublicBaseURL: revision.PublicBaseURL,
WebBaseURL: revision.WebBaseURL, LocalTenantKey: revision.LocalTenantKey,
}.ConsumerMetadata(true, service.appEnv)
if metadataErr != nil {
clear(token)
return PairingExchange{}, metadataErr
}
view, submitErr := remote.SubmitMetadata(ctx, ClaimedExchange{
ExchangeID: pairing.RemoteExchangeID, ExchangeToken: string(token), ExpiresAt: pairing.ExpiresAt, Version: pairing.RemoteVersion,
}, metadata, "gateway-pairing-metadata-"+pairing.ID)
clear(token)
if submitErr != nil {
return PairingExchange{}, submitErr
}
if _, err := service.updateFromRemote(ctx, pairing, view); err != nil {
return PairingExchange{}, err
}
case PairingPreparing:
view, getErr := remote.Get(ctx, pairing.RemoteExchangeID, string(token))
clear(token)
if getErr != nil {
return PairingExchange{}, getErr
}
updated, err := service.updateFromRemote(ctx, pairing, view)
if err != nil {
return PairingExchange{}, err
}
if updated.Status == PairingPreparing {
return updated, nil
}
case PairingReady:
delivery, deliveryErr := remote.DeliverCredential(ctx, Exchange{
ExchangeID: pairing.RemoteExchangeID, ApplicationID: revision.ApplicationID,
Status: ExchangeReady, Version: pairing.RemoteVersion, ExpiresAt: pairing.ExpiresAt,
}, string(token), "gateway-pairing-credential-"+uuid.NewString())
clear(token)
if deliveryErr != nil {
return PairingExchange{}, deliveryErr
}
updatedRevision, saveErr := service.saveDelivery(ctx, revision, pairing, delivery)
if saveErr != nil {
return PairingExchange{}, saveErr
}
_ = updatedRevision
if _, err := service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{
Status: PairingCredentialsSaved, RemoteVersion: delivery.Version,
}); err != nil {
return PairingExchange{}, err
}
case PairingCredentialsSaved:
if revision.SessionRevocation {
if service.security == nil {
clear(token)
return PairingExchange{}, errors.New("security event preparation is unavailable")
}
secret, secretErr := service.secrets.Get(ctx, revision.MachineCredentialRef)
if secretErr != nil {
clear(token)
return PairingExchange{}, secretErr
}
prepareErr := service.security.PrepareSecurityEvents(ctx, revision, secret)
clear(secret)
if prepareErr != nil {
clear(token)
return PairingExchange{}, prepareErr
}
}
completeErr := remote.Complete(ctx, pairing.RemoteExchangeID, string(token), "gateway-pairing-complete-"+pairing.ID, pairing.RemoteVersion)
clear(token)
if completeErr != nil {
return PairingExchange{}, pairingStepError{category: "exchange_completion_failed", cause: completeErr}
}
completed, err := service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{
Status: PairingCompleted, RemoteVersion: pairing.RemoteVersion,
})
if err != nil {
return PairingExchange{}, err
}
service.deleteRetiredIdentitySecret(ctx, pairing.ExchangeTokenRef)
return completed, nil
default:
clear(token)
return PairingExchange{}, ErrRevisionConflict
}
}
return service.repository.IdentityPairingExchange(ctx, pairingID)
}
func isPairingInProgress(status PairingStatus) bool {
switch status {
case PairingMetadataPending, PairingPreparing, PairingReady, PairingCredentialsSaved:
return true
default:
return false
}
}
type safeErrorCategory interface {
SafeErrorCategory() string
}
type pairingStepError struct {
category string
cause error
}
func (err pairingStepError) Error() string { return "identity pairing step failed: " + err.category }
func (err pairingStepError) Unwrap() error { return err.cause }
func (err pairingStepError) SafeErrorCategory() string { return err.category }
func pairingRetryFailureCategory(status PairingStatus, err error) string {
var categorized safeErrorCategory
if errors.As(err, &categorized) && categorized.SafeErrorCategory() == "exchange_completion_failed" {
return "exchange_completion_failed"
}
switch status {
case PairingMetadataPending:
return "metadata_submission_failed"
case PairingPreparing:
return "exchange_status_unavailable"
case PairingReady:
return "credential_delivery_failed"
case PairingCredentialsSaved:
if errors.As(err, &categorized) {
switch categorized.SafeErrorCategory() {
case "configuration_invalid", "connection_conflict", "discovery_failed", "management_token_failed",
"stream_create_failed", "stream_response_invalid", "receiver_activation_failed", "preparation_failed", "retirement_pending",
"credential_handoff_unsafe", "connection_binding_missing", "connection_binding_unavailable",
"connection_binding_invalid", "connection_binding_mismatch":
return "security_event_" + categorized.SafeErrorCategory()
}
}
return "security_event_preparation_failed"
default:
return "pairing_step_failed"
}
}
func pairingCleanupFailureCategory(err error) string {
var categorized safeErrorCategory
if errors.As(err, &categorized) {
switch categorized.SafeErrorCategory() {
case "configuration_invalid", "retirement_pending", "connection_conflict", "connection_cleanup_failed", "secret_cleanup_failed",
"discovery_failed", "management_token_failed", "stream_create_failed", "stream_response_invalid",
"receiver_activation_failed", "preparation_failed", "credential_handoff_unsafe":
return "cleanup_security_event_" + categorized.SafeErrorCategory()
}
}
return "cleanup_security_event_failed"
}
func (service *PairingService) saveDelivery(ctx context.Context, revision Revision, pairing PairingExchange, delivery CredentialDelivery) (Revision, error) {
if err := delivery.Manifest.Validate(service.appEnv); err != nil {
return Revision{}, err
}
machineReference := ""
if delivery.Manifest.Clients.MachineToMachine != nil {
if delivery.MachineCredential == nil || delivery.MachineCredential.ClientID != delivery.Manifest.Clients.MachineToMachine.ClientID {
return Revision{}, errors.New("onboarding machine credential is invalid")
}
machineReference = "identity-machine-" + uuid.NewString()
secret := []byte(delivery.MachineCredential.ClientSecret)
delivery.MachineCredential.ClientSecret = ""
if err := service.stageIdentitySecret(ctx, machineReference, secret); err != nil {
clear(secret)
return Revision{}, err
}
clear(secret)
}
sessionReference := ""
if delivery.Manifest.Clients.BrowserLogin != nil {
sessionReference = "identity-session-" + uuid.NewString()
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
_ = service.retireIdentitySecret(ctx, machineReference)
return Revision{}, err
}
if err := service.stageIdentitySecret(ctx, sessionReference, key); err != nil {
clear(key)
_ = service.retireIdentitySecret(ctx, machineReference)
return Revision{}, err
}
clear(key)
}
updated, err := service.repository.ApplyIdentityManifest(ctx, revision.ID, revision.Version, ManifestApplication{
Manifest: delivery.Manifest, MachineCredentialRef: machineReference, SessionEncryptionKeyRef: sessionReference,
TraceID: pairing.LastTraceID, AuditID: pairing.AuthCenterAuditID, AppEnv: service.appEnv,
})
if err != nil {
// ApplyIdentityManifest adopts the staged references in the same DB
// transaction as the Revision update. On rollback the staging rows
// remain; on an ambiguous successful commit they are gone. Do not
// re-queue here or a valid active credential could be destroyed.
return Revision{}, err
}
return updated, nil
}
func (service *PairingService) stageIdentitySecret(ctx context.Context, reference string, value []byte) error {
if err := service.repository.QueueIdentitySecretCleanup(ctx, reference, time.Now().UTC().Add(10*time.Minute)); err != nil {
return err
}
if err := service.secrets.Put(ctx, reference, value); err != nil {
deleteErr := service.secrets.Delete(ctx, reference)
if deleteErr == nil {
_, _ = service.repository.RemovePendingIdentitySecretCleanup(ctx, reference)
}
return err
}
return nil
}
func (service *PairingService) retireIdentitySecret(ctx context.Context, reference string) error {
if reference == "" {
return nil
}
return service.repository.QueueIdentitySecretCleanup(ctx, reference, time.Now().UTC())
}
func (service *PairingService) deleteRetiredIdentitySecret(ctx context.Context, reference string) {
if err := service.secrets.Delete(ctx, reference); err == nil {
_, _ = service.repository.RemovePendingIdentitySecretCleanup(ctx, reference)
}
}
func (service *PairingService) updateFromRemote(ctx context.Context, pairing PairingExchange, remote Exchange) (PairingExchange, error) {
status := PairingPreparing
category := ""
switch remote.Status {
case ExchangeReady, ExchangeCredentialDelivered:
status = PairingReady
case ExchangeCompleted:
status = PairingFailed
category = "remote_state_invalid"
case ExchangeFailed:
status = PairingFailed
category = "remote_exchange_failed"
case ExchangeExpired:
status = PairingExpired
category = "exchange_expired"
}
return service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{
Status: status, RemoteVersion: remote.Version, LastErrorCategory: category, AuthCenterAuditID: remote.AuditID,
})
}

View File

@ -0,0 +1,880 @@
package identity
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
)
type pairingRepositoryFake struct {
revision Revision
exchange PairingExchange
blocked bool
startReservationErr error
startReservationID string
startReservationState string
startReservationCalls int
startReservationReleases int
pendingSecretCleanups map[string]time.Time
commitStartErr error
commitStartAmbiguous bool
applyManifestAmbiguousOnce bool
failCredentialsSavedOnce bool
}
func (f *pairingRepositoryFake) ReserveIdentityPairingStart(_ context.Context, attemptID string, _ time.Time) error {
f.startReservationCalls++
if f.startReservationErr != nil {
return f.startReservationErr
}
if f.blocked || f.startReservationID != "" {
return ErrPairingInProgress
}
f.startReservationID = attemptID
f.startReservationState = "starting"
return nil
}
func (f *pairingRepositoryFake) ReleaseIdentityPairingStart(_ context.Context, attemptID string) error {
if f.startReservationID == attemptID && f.startReservationState == "starting" {
f.startReservationID = ""
f.startReservationState = ""
f.startReservationReleases++
}
return nil
}
func (f *pairingRepositoryFake) CommitIdentityPairingStart(_ context.Context, revision Revision, exchange PairingExchange) (PairingExchange, error) {
if f.commitStartErr != nil && !f.commitStartAmbiguous {
return PairingExchange{}, f.commitStartErr
}
if f.startReservationID != exchange.ID || f.startReservationState != "starting" {
return PairingExchange{}, ErrPairingInProgress
}
if _, staged := f.pendingSecretCleanups[exchange.ExchangeTokenRef]; !staged {
return PairingExchange{}, errors.New("exchange token was not staged")
}
delete(f.pendingSecretCleanups, exchange.ExchangeTokenRef)
f.revision = revision
f.exchange = exchange
f.startReservationState = "paired"
if f.commitStartAmbiguous {
return PairingExchange{}, f.commitStartErr
}
return exchange, nil
}
func (f *pairingRepositoryFake) CreateIdentityConfigurationRevision(_ context.Context, revision Revision) (Revision, error) {
f.revision = revision
return revision, nil
}
func (f *pairingRepositoryFake) IdentityConfigurationRevision(context.Context, string) (Revision, error) {
return f.revision, nil
}
func (f *pairingRepositoryFake) ApplyIdentityManifest(_ context.Context, _ string, _ int64, applied ManifestApplication) (Revision, error) {
for _, reference := range []string{applied.MachineCredentialRef, applied.SessionEncryptionKeyRef} {
if reference == "" {
continue
}
if _, staged := f.pendingSecretCleanups[reference]; !staged {
return Revision{}, errors.New("identity secret was not staged")
}
}
oldMachineReference, oldSessionReference := f.revision.MachineCredentialRef, f.revision.SessionEncryptionKeyRef
revision, err := ApplyManifest(f.revision, applied)
if err != nil {
return Revision{}, err
}
revision.Version++
f.revision = revision
for _, reference := range []string{applied.MachineCredentialRef, applied.SessionEncryptionKeyRef} {
delete(f.pendingSecretCleanups, reference)
}
for _, reference := range []string{oldMachineReference, oldSessionReference} {
if reference != "" && reference != applied.MachineCredentialRef && reference != applied.SessionEncryptionKeyRef {
f.pendingSecretCleanups[reference] = time.Now()
}
}
if f.applyManifestAmbiguousOnce {
f.applyManifestAmbiguousOnce = false
return Revision{}, errors.New("manifest commit response lost")
}
return revision, nil
}
func (f *pairingRepositoryFake) CreateIdentityPairingExchange(_ context.Context, exchange PairingExchange) (PairingExchange, error) {
f.exchange = exchange
return exchange, nil
}
func (f *pairingRepositoryFake) IdentityPairingExchange(context.Context, string) (PairingExchange, error) {
return f.exchange, nil
}
func (f *pairingRepositoryFake) UpdateIdentityPairingExchange(_ context.Context, id string, expected int64, update PairingExchangeUpdate) (PairingExchange, error) {
if f.exchange.ID != id || f.exchange.Version != expected {
return PairingExchange{}, ErrRevisionConflict
}
if update.Status == PairingCredentialsSaved && f.failCredentialsSavedOnce {
f.failCredentialsSavedOnce = false
return PairingExchange{}, errors.New("pairing status temporarily unavailable")
}
f.exchange.Status, f.exchange.RemoteVersion = update.Status, update.RemoteVersion
f.exchange.LastErrorCategory, f.exchange.AuthCenterAuditID = update.LastErrorCategory, update.AuthCenterAuditID
f.exchange.Version++
if update.Status == PairingCompleted || update.Status == PairingFailed || update.Status == PairingExpired {
if f.pendingSecretCleanups == nil {
f.pendingSecretCleanups = map[string]time.Time{}
}
f.pendingSecretCleanups[f.exchange.ExchangeTokenRef] = time.Now()
}
return f.exchange, nil
}
func (f *pairingRepositoryFake) RecordIdentityPairingRetryFailure(_ context.Context, id string, status PairingStatus, category string) (PairingExchange, error) {
if f.exchange.ID != id || f.exchange.Status != status {
return PairingExchange{}, ErrRevisionConflict
}
f.exchange.LastErrorCategory = category
return f.exchange, nil
}
func (f *pairingRepositoryFake) IdentityPairingStartBlocked(context.Context) (bool, error) {
return f.blocked, nil
}
func (f *pairingRepositoryFake) QueueIdentitySecretCleanup(_ context.Context, reference string, notBefore time.Time) error {
if f.pendingSecretCleanups == nil {
f.pendingSecretCleanups = map[string]time.Time{}
}
current, exists := f.pendingSecretCleanups[reference]
if !exists || notBefore.Before(current) {
f.pendingSecretCleanups[reference] = notBefore
}
return nil
}
func (f *pairingRepositoryFake) RemovePendingIdentitySecretCleanup(_ context.Context, reference string) (bool, error) {
if _, exists := f.pendingSecretCleanups[reference]; !exists {
return false, nil
}
delete(f.pendingSecretCleanups, reference)
return true, nil
}
func (f *pairingRepositoryFake) CancelIdentityPairingExchange(_ context.Context, id string, expected int64, traceID, auditID string) (PairingExchange, error) {
if f.exchange.ID != id || f.revision.State == RevisionActive || f.revision.State == RevisionSuperseded {
return PairingExchange{}, ErrRevisionConflict
}
if f.exchange.Status == PairingCancelled {
return f.exchange, nil
}
if f.exchange.Version != expected {
return PairingExchange{}, ErrRevisionConflict
}
f.exchange.Status = PairingCancelled
f.exchange.CleanupStatus = PairingCleanupPending
f.exchange.Version++
now := time.Now().UTC()
f.exchange.CancelledAt = &now
f.revision.State = RevisionFailed
f.revision.LastErrorCategory = "pairing_cancelled"
f.revision.LastTraceID = traceID
f.revision.LastAuditID = auditID
f.revision.Version++
if f.exchange.ExchangeTokenRef != "" {
if f.pendingSecretCleanups == nil {
f.pendingSecretCleanups = map[string]time.Time{}
}
f.pendingSecretCleanups[f.exchange.ExchangeTokenRef] = time.Now()
}
return f.exchange, nil
}
func (f *pairingRepositoryFake) CompleteIdentityPairingCleanup(_ context.Context, id string, expected int64) (PairingExchange, error) {
if f.exchange.ID != id || f.exchange.Version != expected || f.exchange.Status != PairingCancelled || f.exchange.CleanupStatus != PairingCleanupPending {
return PairingExchange{}, ErrRevisionConflict
}
f.exchange.CleanupStatus = PairingCleanupCompleted
f.exchange.Version++
now := time.Now().UTC()
f.exchange.CleanupCompletedAt = &now
f.revision.MachineCredentialRef = ""
f.revision.SessionEncryptionKeyRef = ""
if f.startReservationState == "paired" && f.startReservationID == f.exchange.ID {
f.startReservationID = ""
f.startReservationState = ""
}
return f.exchange, nil
}
func (f *pairingRepositoryFake) RecordIdentityPairingCleanupFailure(_ context.Context, id string, category string) (PairingExchange, error) {
if f.exchange.ID != id || f.exchange.Status != PairingCancelled || f.exchange.CleanupStatus != PairingCleanupPending {
return PairingExchange{}, ErrRevisionConflict
}
f.exchange.LastErrorCategory = category
return f.exchange, nil
}
type secretStoreFake struct {
values map[string][]byte
failMachine bool
}
func (f *secretStoreFake) Put(_ context.Context, reference string, value []byte) error {
if f.failMachine && len(reference) > len("identity-machine-") && reference[:len("identity-machine-")] == "identity-machine-" {
return errors.New("secret store unavailable")
}
if f.values == nil {
f.values = map[string][]byte{}
}
f.values[reference] = append([]byte(nil), value...)
return nil
}
func (f *secretStoreFake) Get(_ context.Context, reference string) ([]byte, error) {
value, ok := f.values[reference]
if !ok {
return nil, errors.New("secret not found")
}
return append([]byte(nil), value...), nil
}
func (f *secretStoreFake) Delete(_ context.Context, reference string) error {
delete(f.values, reference)
return nil
}
type onboardingRemoteFake struct {
claimed ClaimedExchange
claimErr error
claimCalls int
view Exchange
delivery CredentialDelivery
deliveryCalls int
completed bool
completeErr error
}
func (f *onboardingRemoteFake) Claim(context.Context, string) (ClaimedExchange, error) {
f.claimCalls++
return f.claimed, f.claimErr
}
func (f *onboardingRemoteFake) SubmitMetadata(context.Context, ClaimedExchange, ConsumerMetadata, string) (Exchange, error) {
f.view.Status, f.view.Version = ExchangePreparing, 2
return f.view, nil
}
func (f *onboardingRemoteFake) Get(context.Context, string, string) (Exchange, error) {
f.view.Status, f.view.Version = ExchangeReady, 3
return f.view, nil
}
func (f *onboardingRemoteFake) DeliverCredential(context.Context, Exchange, string, string) (CredentialDelivery, error) {
f.deliveryCalls++
f.delivery.MachineCredential.ClientSecret = "rotated-machine-secret-value-000000000000-" + string(rune('0'+f.deliveryCalls))
f.delivery.Version = int64(3 + f.deliveryCalls)
return f.delivery, nil
}
func (f *onboardingRemoteFake) Complete(context.Context, string, string, string, int64) error {
f.completed = true
return f.completeErr
}
type securityEventPreparerFake struct {
called bool
err error
cleanupCalled bool
cleanupErr error
conflictRetireCalled bool
conflictRetireErr error
conflictRevisionID string
}
type blockingSecurityEventPreparer struct {
prepareStarted chan struct{}
releasePrepare chan struct{}
cleanupStarted chan struct{}
cleanupOnce sync.Once
}
func (preparer *blockingSecurityEventPreparer) PrepareSecurityEvents(context.Context, Revision, []byte) error {
close(preparer.prepareStarted)
<-preparer.releasePrepare
return nil
}
func (preparer *blockingSecurityEventPreparer) CleanupPreparedSecurityEvents(context.Context, Revision) error {
preparer.cleanupOnce.Do(func() { close(preparer.cleanupStarted) })
return nil
}
func (f *securityEventPreparerFake) PrepareSecurityEvents(context.Context, Revision, []byte) error {
f.called = true
return f.err
}
func (f *securityEventPreparerFake) CleanupPreparedSecurityEvents(context.Context, Revision) error {
f.cleanupCalled = true
return f.cleanupErr
}
func (f *securityEventPreparerFake) RetireConflictingSecurityEvents(_ context.Context, revision Revision) error {
f.conflictRetireCalled = true
f.conflictRevisionID = revision.ID
return f.conflictRetireErr
}
type safeCategoryTestError struct {
category string
message string
}
func (err safeCategoryTestError) Error() string { return err.message }
func (err safeCategoryTestError) SafeErrorCategory() string { return err.category }
func TestPairingStartReservationSerializesAndRecoversFailedClaims(t *testing.T) {
input := PairingInput{
AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value",
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
}
t.Run("existing pairing blocks before remote claim", func(t *testing.T) {
repository := &pairingRepositoryFake{blocked: true}
remote := &onboardingRemoteFake{}
service := NewPairingService(repository, &secretStoreFake{}, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
if _, err := service.Start(context.Background(), input, "trace-blocked"); !errors.Is(err, ErrPairingInProgress) {
t.Fatalf("blocked Start error=%v", err)
}
if repository.startReservationCalls != 1 || repository.startReservationReleases != 0 || repository.startReservationID != "" || remote.claimCalls != 0 {
t.Fatalf("reservation/claim lifecycle calls=%d releases=%d id=%q claims=%d",
repository.startReservationCalls, repository.startReservationReleases, repository.startReservationID, remote.claimCalls)
}
})
t.Run("active machine credential blocks destructive rotation before remote claim", func(t *testing.T) {
repository := &pairingRepositoryFake{startReservationErr: ErrActiveConfigurationHandoffRequired}
remote := &onboardingRemoteFake{}
service := NewPairingService(repository, &secretStoreFake{}, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
if _, err := service.Start(context.Background(), input, "trace-active"); !errors.Is(err, ErrActiveConfigurationHandoffRequired) {
t.Fatalf("active credential guard error=%v", err)
}
if remote.claimCalls != 0 {
t.Fatal("active credential guard ran after the one-time onboarding code was claimed")
}
})
t.Run("remote failure releases database guard", func(t *testing.T) {
repository := &pairingRepositoryFake{}
remote := &onboardingRemoteFake{claimErr: errors.New("remote unavailable")}
service := NewPairingService(repository, &secretStoreFake{}, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
if _, err := service.Start(context.Background(), input, "trace-failed"); err == nil {
t.Fatal("remote claim failure was ignored")
}
if repository.startReservationCalls != 1 || repository.startReservationReleases != 1 || repository.startReservationID != "" || remote.claimCalls != 1 {
t.Fatalf("reservation was not released after remote failure: %#v remote=%#v", repository, remote)
}
})
}
func TestPairingStartLeavesUncommittedExchangeTokenInDurableCleanupQueue(t *testing.T) {
repository := &pairingRepositoryFake{commitStartErr: errors.New("database commit unavailable")}
secrets := &secretStoreFake{values: map[string][]byte{}}
remote := &onboardingRemoteFake{claimed: ClaimedExchange{
ExchangeID: "11111111-1111-1111-1111-111111111111", ExchangeToken: "exchange-token-value",
Version: 1, ExpiresAt: time.Now().Add(time.Hour),
}}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
if _, err := service.Start(context.Background(), PairingInput{
AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value",
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
}, "trace-uncommitted"); err == nil {
t.Fatal("failed pairing commit was ignored")
}
if len(secrets.values) != 1 || len(repository.pendingSecretCleanups) != 1 || repository.startReservationID != "" || repository.startReservationReleases != 1 {
t.Fatalf("uncommitted token was not left recoverable: secrets=%d cleanup=%d reservation=%q releases=%d",
len(secrets.values), len(repository.pendingSecretCleanups), repository.startReservationID, repository.startReservationReleases)
}
for reference := range secrets.values {
if _, queued := repository.pendingSecretCleanups[reference]; !queued {
t.Fatalf("uncommitted Secret reference %q is not queued for cleanup", reference)
}
}
}
func TestPairingStartAmbiguousCommitNeverRequeuesAdoptedExchangeToken(t *testing.T) {
repository := &pairingRepositoryFake{
commitStartErr: errors.New("commit response lost"), commitStartAmbiguous: true,
}
secrets := &secretStoreFake{values: map[string][]byte{}}
remote := &onboardingRemoteFake{claimed: ClaimedExchange{
ExchangeID: "11111111-1111-1111-1111-111111111111", ExchangeToken: "exchange-token-value",
Version: 1, ExpiresAt: time.Now().Add(time.Hour),
}}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
if _, err := service.Start(context.Background(), PairingInput{
AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value",
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
}, "trace-ambiguous"); err == nil {
t.Fatal("ambiguous pairing commit was not reported")
}
if repository.exchange.ID == "" || repository.revision.ID == "" {
t.Fatal("test did not simulate a committed Pairing")
}
if len(secrets.values) != 1 || len(repository.pendingSecretCleanups) != 0 {
t.Fatalf("adopted exchange token was re-queued: secrets=%d cleanup=%d", len(secrets.values), len(repository.pendingSecretCleanups))
}
if repository.startReservationID != repository.exchange.ID || repository.startReservationState != "paired" || repository.startReservationReleases != 0 {
t.Fatalf("committed reservation was not retained: id=%q state=%q releases=%d",
repository.startReservationID, repository.startReservationState, repository.startReservationReleases)
}
}
func TestPairingRetriesWithRotatedCredentialAfterSecretStoreFailure(t *testing.T) {
repository := &pairingRepositoryFake{}
secrets := &secretStoreFake{values: map[string][]byte{}, failMachine: true}
remote := &onboardingRemoteFake{
claimed: ClaimedExchange{ExchangeID: "11111111-1111-1111-1111-111111111111", ExchangeToken: "exchange-token-value", Version: 1, ExpiresAt: time.Now().Add(time.Hour)},
view: Exchange{ExchangeID: "11111111-1111-1111-1111-111111111111", ApplicationID: "22222222-2222-2222-2222-222222222222", Version: 1, ExpiresAt: time.Now().Add(time.Hour)},
delivery: CredentialDelivery{Manifest: ManifestV1{
SchemaVersion: 1, Issuer: "https://auth.example.com/issuer/shared", TenantID: "33333333-3333-3333-3333-333333333333",
ApplicationID: "22222222-2222-2222-2222-222222222222", Audience: "urn:easyai:resource:22222222-2222-2222-2222-222222222222",
Capabilities: []string{"oidc_login", "api_access", "machine_to_machine", "token_introspection", "session_revocation"}, Scopes: []string{"openid", "gateway.access"},
Clients: ManifestClients{BrowserLogin: &ManifestClient{ClientID: "browser"}, MachineToMachine: &ManifestClient{ClientID: "service"}},
SecurityEvents: &ManifestSecurityEvents{TransmitterIssuer: "https://auth.example.com/ssf", ConfigurationEndpoint: "https://auth.example.com/.well-known/ssf-configuration/ssf", Audience: "urn:easyai:ssf:receiver:22222222-2222-2222-2222-222222222222"},
}, MachineCredential: &MachineCredential{ClientID: "service", ClientSecret: "placeholder", IssuedAt: time.Now()}},
}
preparer := &securityEventPreparerFake{}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, preparer)
pairing, err := service.Start(context.Background(), PairingInput{
AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value",
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
}, "trace-pairing")
if err != nil {
t.Fatal(err)
}
if _, ok := secrets.values[pairing.ExchangeTokenRef]; !ok {
t.Fatal("exchange token was not saved in SecretStore")
}
if _, err := service.Continue(context.Background(), pairing.ID); err == nil {
t.Fatal("machine SecretStore failure was ignored")
}
if remote.completed || repository.revision.ApplicationID != "" {
t.Fatal("failed secret save advanced the exchange or revision")
}
secrets.failMachine = false
completed, err := service.Continue(context.Background(), pairing.ID)
if err != nil {
t.Fatal(err)
}
if !remote.completed || completed.Status != PairingCompleted || remote.deliveryCalls != 2 || !preparer.called {
t.Fatalf("pairing did not recover safely: pairing=%#v calls=%d completed=%v", completed, remote.deliveryCalls, remote.completed)
}
if repository.revision.MachineCredentialRef == "" || repository.revision.SessionEncryptionKeyRef == "" {
t.Fatalf("revision did not retain SecretStore references: %#v", repository.revision)
}
}
func TestPairingRetryNeverOverwritesOrDeletesAdoptedIdentitySecrets(t *testing.T) {
repository := &pairingRepositoryFake{applyManifestAmbiguousOnce: true, failCredentialsSavedOnce: true}
secrets := &secretStoreFake{values: map[string][]byte{}}
remote := &onboardingRemoteFake{
claimed: ClaimedExchange{ExchangeID: "11111111-1111-1111-1111-111111111111", ExchangeToken: "exchange-token-value", Version: 1, ExpiresAt: time.Now().Add(time.Hour)},
view: Exchange{ExchangeID: "11111111-1111-1111-1111-111111111111", ApplicationID: "22222222-2222-2222-2222-222222222222", Version: 1, ExpiresAt: time.Now().Add(time.Hour)},
delivery: CredentialDelivery{Manifest: ManifestV1{
SchemaVersion: 1, Issuer: "https://auth.example.com/issuer/shared", TenantID: "33333333-3333-3333-3333-333333333333",
ApplicationID: "22222222-2222-2222-2222-222222222222", Audience: "urn:easyai:resource:22222222-2222-2222-2222-222222222222",
Capabilities: []string{"oidc_login", "api_access", "machine_to_machine", "token_introspection", "session_revocation"}, Scopes: []string{"openid", "gateway.access"},
Clients: ManifestClients{BrowserLogin: &ManifestClient{ClientID: "browser"}, MachineToMachine: &ManifestClient{ClientID: "service"}},
SecurityEvents: &ManifestSecurityEvents{TransmitterIssuer: "https://auth.example.com/ssf", ConfigurationEndpoint: "https://auth.example.com/.well-known/ssf-configuration/ssf", Audience: "urn:easyai:ssf:receiver:22222222-2222-2222-2222-222222222222"},
}, MachineCredential: &MachineCredential{ClientID: "service", ClientSecret: "placeholder", IssuedAt: time.Now()}},
}
preparer := &securityEventPreparerFake{}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, preparer)
pairing, err := service.Start(context.Background(), PairingInput{
AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value",
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
}, "trace-retry")
if err != nil {
t.Fatal(err)
}
if _, err := service.Continue(context.Background(), pairing.ID); err == nil {
t.Fatal("simulated pairing status persistence failure was ignored")
}
firstMachine := repository.revision.MachineCredentialRef
firstSession := repository.revision.SessionEncryptionKeyRef
if firstMachine == "" || firstSession == "" {
t.Fatalf("first delivery was not adopted: %#v", repository.revision)
}
if _, ok := secrets.values[firstMachine]; !ok {
t.Fatal("first adopted machine Secret was deleted")
}
if _, ok := secrets.values[firstSession]; !ok {
t.Fatal("first adopted session Secret was deleted")
}
for _, reference := range []string{firstMachine, firstSession} {
if _, queued := repository.pendingSecretCleanups[reference]; queued {
t.Fatalf("ambiguously adopted identity Secret %q was re-queued", reference)
}
}
if _, err := service.Continue(context.Background(), pairing.ID); err == nil {
t.Fatal("simulated pairing status persistence failure was ignored")
}
secondMachine := repository.revision.MachineCredentialRef
secondSession := repository.revision.SessionEncryptionKeyRef
if secondMachine == firstMachine || secondSession == firstSession {
t.Fatalf("identity Secret references were overwritten in place: first=(%s,%s) second=(%s,%s)", firstMachine, firstSession, secondMachine, secondSession)
}
for _, reference := range []string{firstMachine, firstSession} {
if _, queued := repository.pendingSecretCleanups[reference]; !queued {
t.Fatalf("first superseded identity Secret %q was not queued for cleanup", reference)
}
}
completed, err := service.Continue(context.Background(), pairing.ID)
if err != nil {
t.Fatal(err)
}
if completed.Status != PairingCompleted {
t.Fatalf("retry did not complete: %#v", completed)
}
thirdMachine := repository.revision.MachineCredentialRef
thirdSession := repository.revision.SessionEncryptionKeyRef
if thirdMachine == secondMachine || thirdSession == secondSession {
t.Fatalf("second retry overwrote identity Secret references in place: second=(%s,%s) third=(%s,%s)", secondMachine, secondSession, thirdMachine, thirdSession)
}
for _, reference := range []string{thirdMachine, thirdSession} {
if _, ok := secrets.values[reference]; !ok {
t.Fatalf("active identity Secret %q was deleted", reference)
}
if _, queued := repository.pendingSecretCleanups[reference]; queued {
t.Fatalf("active identity Secret %q remained in cleanup queue", reference)
}
}
for _, reference := range []string{firstMachine, firstSession, secondMachine, secondSession} {
if _, queued := repository.pendingSecretCleanups[reference]; !queued {
t.Fatalf("superseded identity Secret %q was not queued for cleanup", reference)
}
}
}
func TestPairingExpirationIsPersistedAndExchangeTokenIsDestroyed(t *testing.T) {
repository := &pairingRepositoryFake{exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", ExchangeTokenRef: "identity-exchange-pairing",
Status: PairingPreparing, RemoteVersion: 2, ExpiresAt: time.Now().Add(-time.Minute), Version: 4,
}}
secrets := &secretStoreFake{values: map[string][]byte{"identity-exchange-pairing": []byte("temporary-token")}}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) {
t.Fatal("expired exchange must not call the remote service")
return nil, nil
}, nil)
expired, err := service.Continue(context.Background(), "pairing")
if err != nil {
t.Fatal(err)
}
if expired.Status != PairingExpired || expired.LastErrorCategory != "exchange_expired" {
t.Fatalf("expired pairing was not persisted: %#v", expired)
}
if _, exists := secrets.values["identity-exchange-pairing"]; exists {
t.Fatal("expired exchange token remained in SecretStore")
}
}
func TestPairingPersistsOnlySafeSecurityEventFailureCategory(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{
ID: "revision", State: RevisionDraft, AuthCenterURL: "https://auth.example.com",
SessionRevocation: true, MachineCredentialRef: "identity-machine-revision",
},
exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", RemoteExchangeID: "11111111-1111-1111-1111-111111111111",
ExchangeTokenRef: "identity-exchange-pairing", Status: PairingCredentialsSaved,
RemoteVersion: 4, ExpiresAt: time.Now().Add(time.Hour), Version: 7,
},
}
secrets := &secretStoreFake{values: map[string][]byte{
"identity-exchange-pairing": []byte("temporary-exchange-token-value-000000"),
"identity-machine-revision": []byte("temporary-machine-secret-value-0000000"),
}}
remote := &onboardingRemoteFake{}
preparer := &securityEventPreparerFake{err: safeCategoryTestError{
category: "discovery_failed",
message: "sensitive-marker https://internal.example/ssf token-value",
}}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, preparer)
failed, err := service.Continue(context.Background(), "pairing")
if err == nil {
t.Fatal("security event discovery failure was ignored")
}
if failed.Status != PairingCredentialsSaved || failed.LastErrorCategory != "security_event_discovery_failed" {
t.Fatalf("unsafe or missing pairing failure state: %#v", failed)
}
encoded, marshalErr := json.Marshal(failed)
if marshalErr != nil {
t.Fatal(marshalErr)
}
if strings.Contains(string(encoded), "sensitive-marker") || strings.Contains(string(encoded), "internal.example") || strings.Contains(string(encoded), "token-value") {
t.Fatalf("raw security event error escaped through pairing JSON: %s", encoded)
}
preparer.err = nil
completed, err := service.Continue(context.Background(), "pairing")
if err != nil {
t.Fatal(err)
}
if completed.Status != PairingCompleted || completed.LastErrorCategory != "" {
t.Fatalf("successful retry did not clear the diagnostic category: %#v", completed)
}
}
func TestPairingCancellationPersistsIntentBeforeCleaningTemporaryResources(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{
ID: "revision", State: RevisionDraft, Version: 3, SessionRevocation: true,
MachineCredentialRef: "identity-machine-revision", SessionEncryptionKeyRef: "identity-session-revision",
},
exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", ExchangeTokenRef: "identity-exchange-pairing",
Status: PairingCredentialsSaved, CleanupStatus: PairingCleanupNone, RemoteVersion: 4,
ExpiresAt: time.Now().Add(time.Hour), Version: 7,
},
}
secrets := &secretStoreFake{values: map[string][]byte{
"identity-exchange-pairing": []byte("temporary-exchange-token-value-000000"),
"identity-machine-revision": []byte("temporary-machine-secret-value-0000000"),
"identity-session-revision": []byte("temporary-session-secret-value-0000000"),
}}
preparer := &securityEventPreparerFake{}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) {
t.Fatal("cancellation must not call the onboarding remote")
return nil, nil
}, preparer)
cancelled, err := service.Cancel(context.Background(), "pairing", 7, "trace-cancel", "audit-cancel")
if err != nil {
t.Fatal(err)
}
if cancelled.Status != PairingCancelled || cancelled.CleanupStatus != PairingCleanupPending || cancelled.CancelledAt == nil {
t.Fatalf("cancellation intent was not persisted: %#v", cancelled)
}
if repository.revision.State != RevisionFailed || repository.revision.LastErrorCategory != "pairing_cancelled" {
t.Fatalf("draft was not atomically retired: %#v", repository.revision)
}
if len(secrets.values) != 3 {
t.Fatal("synchronous cancellation removed resources before the cleanup worker could resume them")
}
replayed, err := service.Cancel(context.Background(), "pairing", 7, "trace-replay", "audit-replay")
if err != nil || replayed.Version != cancelled.Version || replayed.Status != PairingCancelled {
t.Fatalf("lost cancellation response could not be replayed safely: %#v err=%v", replayed, err)
}
cleaned, err := service.Cleanup(context.Background(), "pairing")
if err != nil {
t.Fatal(err)
}
if cleaned.CleanupStatus != PairingCleanupCompleted || cleaned.CleanupCompletedAt == nil || !preparer.cleanupCalled {
t.Fatalf("pairing cleanup was not completed: %#v", cleaned)
}
if len(secrets.values) != 0 || repository.revision.MachineCredentialRef != "" || repository.revision.SessionEncryptionKeyRef != "" {
t.Fatalf("temporary secret references survived cleanup: secrets=%d revision=%#v", len(secrets.values), repository.revision)
}
}
func TestPairingCleanupRetriesWithSafeCategory(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{ID: "revision", State: RevisionFailed, Version: 4, SessionRevocation: true},
exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", Status: PairingCancelled, CleanupStatus: PairingCleanupPending,
RemoteVersion: 4, ExpiresAt: time.Now().Add(time.Hour), Version: 8,
},
}
preparer := &securityEventPreparerFake{cleanupErr: safeCategoryTestError{
category: "retirement_pending", message: "sensitive cleanup marker token-value",
}}
service := NewPairingService(repository, &secretStoreFake{values: map[string][]byte{}}, nil, preparer)
pending, err := service.Cleanup(context.Background(), "pairing")
if err == nil {
t.Fatal("pending security event retirement was treated as completed")
}
if pending.CleanupStatus != PairingCleanupPending || pending.LastErrorCategory != "cleanup_security_event_retirement_pending" || strings.Contains(pending.LastErrorCategory, "sensitive") {
t.Fatalf("cleanup failure was not safely persisted: %#v", pending)
}
preparer.cleanupErr = nil
completed, err := service.Cleanup(context.Background(), "pairing")
if err != nil {
t.Fatal(err)
}
if completed.CleanupStatus != PairingCleanupCompleted {
t.Fatalf("cleanup retry did not complete: %#v", completed)
}
}
func TestPairingMapsUntrustedRemoteErrorCategoryToStableValue(t *testing.T) {
repository := &pairingRepositoryFake{exchange: PairingExchange{ID: "pairing", Version: 2}}
service := NewPairingService(repository, &secretStoreFake{}, nil, nil)
updated, err := service.updateFromRemote(context.Background(), repository.exchange, Exchange{
Status: ExchangeFailed, Version: 3, LastErrorCategory: "secret_token_abcdef",
})
if err != nil {
t.Fatal(err)
}
if updated.Status != PairingFailed || updated.LastErrorCategory != "remote_exchange_failed" {
t.Fatalf("untrusted remote category was persisted: %#v", updated)
}
}
func TestPairingConflictRetirementIsScopedToCurrentBlockedPairing(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{ID: "revision", State: RevisionDraft, Version: 3, SessionRevocation: true},
exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", Status: PairingCredentialsSaved,
CleanupStatus: PairingCleanupNone, Version: 7, LastErrorCategory: "security_event_connection_conflict",
},
}
preparer := &securityEventPreparerFake{}
service := NewPairingService(repository, &secretStoreFake{}, nil, preparer)
resolved, err := service.RetireConflictingSecurityEvents(context.Background(), "pairing", 7)
if err != nil {
t.Fatal(err)
}
if resolved.ID != "pairing" || resolved.LastErrorCategory != "security_event_retirement_pending" || !preparer.conflictRetireCalled || preparer.conflictRevisionID != "revision" {
t.Fatalf("conflict retirement escaped pairing scope: pairing=%#v preparer=%#v", resolved, preparer)
}
preparer.conflictRetireCalled = false
if _, err := service.RetireConflictingSecurityEvents(context.Background(), "pairing", 6); !errors.Is(err, ErrRevisionConflict) {
t.Fatalf("stale pairing version error=%v", err)
}
repository.exchange.LastErrorCategory = ""
if _, err := service.RetireConflictingSecurityEvents(context.Background(), "pairing", 7); !errors.Is(err, ErrPairingConflictNotResolvable) {
t.Fatalf("resolved pairing could retire another connection: %v", err)
}
if preparer.conflictRetireCalled {
t.Fatal("stale conflict action reached the security event manager")
}
}
func TestCompletedPairingRestoresPreparedReceiverAfterRestart(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{
ID: "revision", State: RevisionValidated, SessionRevocation: true,
MachineCredentialRef: "identity-machine-revision",
},
exchange: PairingExchange{ID: "pairing", RevisionID: "revision", Status: PairingCompleted, Version: 8},
}
secrets := &secretStoreFake{values: map[string][]byte{
"identity-machine-revision": []byte("machine-secret-value-restored-from-store"),
}}
preparer := &securityEventPreparerFake{}
service := NewPairingService(repository, secrets, nil, preparer)
if err := service.RestoreCompletedSecurityEvents(context.Background(), "pairing"); err != nil {
t.Fatal(err)
}
if !preparer.called {
t.Fatal("completed pairing did not reconstruct its prepared security event receiver")
}
}
func TestCompletedPairingDoesNotRestoreReceiverForSupersededRevision(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{
ID: "revision", State: RevisionSuperseded, Version: 5, SessionRevocation: true,
MachineCredentialRef: "identity-machine-revision",
},
exchange: PairingExchange{ID: "pairing", RevisionID: "revision", Status: PairingCompleted, Version: 8},
}
preparer := &securityEventPreparerFake{}
service := NewPairingService(repository, &secretStoreFake{values: map[string][]byte{
"identity-machine-revision": []byte("must-not-be-read"),
}}, nil, preparer)
if err := service.RestoreCompletedSecurityEvents(context.Background(), "pairing"); !errors.Is(err, ErrRevisionConflict) {
t.Fatalf("restore error=%v, want revision conflict", err)
}
if preparer.called {
t.Fatal("superseded Revision reconstructed an orphan prepared Receiver")
}
}
func TestCancelledPairingCleanupWaitsForCompletedReceiverRestore(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{
ID: "revision", State: RevisionValidated, Version: 3, SessionRevocation: true,
MachineCredentialRef: "identity-machine-revision",
},
exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", ExchangeTokenRef: "identity-exchange-pairing",
Status: PairingCompleted, CleanupStatus: PairingCleanupNone, Version: 8,
},
}
secrets := &secretStoreFake{values: map[string][]byte{
"identity-exchange-pairing": []byte("exchange-token"),
"identity-machine-revision": []byte("machine-secret"),
}}
preparer := &blockingSecurityEventPreparer{
prepareStarted: make(chan struct{}),
releasePrepare: make(chan struct{}),
cleanupStarted: make(chan struct{}),
}
service := NewPairingService(repository, secrets, nil, preparer)
restoreResult := make(chan error, 1)
go func() { restoreResult <- service.RestoreCompletedSecurityEvents(context.Background(), "pairing") }()
<-preparer.prepareStarted
cancelled, err := service.Cancel(context.Background(), "pairing", 8, "trace", "audit")
if err != nil {
t.Fatal(err)
}
cleanupResult := make(chan PairingExchange, 1)
cleanupError := make(chan error, 1)
go func() {
cleaned, cleanupErr := service.Cleanup(context.Background(), "pairing")
cleanupResult <- cleaned
cleanupError <- cleanupErr
}()
select {
case <-preparer.cleanupStarted:
t.Fatal("cleanup overtook an in-flight completed receiver restore")
case <-time.After(100 * time.Millisecond):
}
close(preparer.releasePrepare)
if err := <-restoreResult; !errors.Is(err, ErrRevisionConflict) {
t.Fatalf("restore error = %v, want revision conflict after cancellation", err)
}
if err := <-cleanupError; err != nil {
t.Fatal(err)
}
cleaned := <-cleanupResult
if cancelled.Status != PairingCancelled || cleaned.CleanupStatus != PairingCleanupCompleted {
t.Fatalf("cancel/cleanup did not converge: cancelled=%#v cleaned=%#v", cancelled, cleaned)
}
if len(secrets.values) != 0 {
t.Fatalf("cancelled restore left temporary secrets: %d", len(secrets.values))
}
}
func TestPairingClassifiesExchangeCompletionFailureSeparatelyFromSSF(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{ID: "revision", State: RevisionDraft, AuthCenterURL: "https://auth.example.com"},
exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", RemoteExchangeID: "11111111-1111-1111-1111-111111111111",
ExchangeTokenRef: "identity-exchange-pairing", Status: PairingCredentialsSaved,
RemoteVersion: 4, ExpiresAt: time.Now().Add(time.Hour), Version: 7,
},
}
secrets := &secretStoreFake{values: map[string][]byte{
"identity-exchange-pairing": []byte("temporary-exchange-token-value-000000"),
}}
remote := &onboardingRemoteFake{completeErr: errors.New("upstream body contains sensitive marker")}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
failed, err := service.Continue(context.Background(), "pairing")
if err == nil {
t.Fatal("exchange completion failure was ignored")
}
if failed.LastErrorCategory != "exchange_completion_failed" {
t.Fatalf("completion failure was misclassified: %#v", failed)
}
}

View File

@ -0,0 +1,419 @@
package identityruntime
import (
"context"
"errors"
"net/http"
"net/url"
"slices"
"strings"
"sync"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type RuntimeBuilderConfig struct {
AppEnv string
JWKSCacheTTL time.Duration
HeartbeatInterval time.Duration
StaleAfter time.Duration
ClockSkew time.Duration
}
type preparedSecurityRuntime struct {
manager *securityevents.ConnectionManager
cancel context.CancelFunc
receiverReady bool
blockedCategory string
}
type safeRuntimeError struct {
category string
cause error
}
func (err safeRuntimeError) Error() string {
return "identity runtime operation failed: " + err.category
}
func (err safeRuntimeError) Unwrap() error { return err.cause }
func (err safeRuntimeError) SafeErrorCategory() string { return err.category }
type RuntimeBuilder struct {
ctx context.Context
store *store.Store
secrets PairingSecretStore
config RuntimeBuilderConfig
metrics *securityevents.Metrics
mutex sync.Mutex
prepared map[string]preparedSecurityRuntime
}
type PairingSecretStore interface {
Put(context.Context, string, []byte) error
Get(context.Context, string) ([]byte, error)
Delete(context.Context, string) error
}
func NewRuntimeBuilder(ctx context.Context, data *store.Store, secrets PairingSecretStore, config RuntimeBuilderConfig, metrics *securityevents.Metrics) *RuntimeBuilder {
if metrics == nil {
metrics = &securityevents.Metrics{}
}
if config.JWKSCacheTTL <= 0 {
config.JWKSCacheTTL = 5 * time.Minute
}
return &RuntimeBuilder{ctx: ctx, store: data, secrets: secrets, config: config, metrics: metrics, prepared: map[string]preparedSecurityRuntime{}}
}
func (builder *RuntimeBuilder) PreparedSecurityEventReceiver() http.Handler {
builder.mutex.Lock()
defer builder.mutex.Unlock()
if len(builder.prepared) != 1 {
return nil
}
for _, prepared := range builder.prepared {
if prepared.receiverReady {
return prepared.manager
}
}
return nil
}
func (builder *RuntimeBuilder) PreparedSecurityEventManager() *securityevents.ConnectionManager {
builder.mutex.Lock()
defer builder.mutex.Unlock()
if len(builder.prepared) != 1 {
return nil
}
for _, prepared := range builder.prepared {
return prepared.manager
}
return nil
}
func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revision) (*Runtime, error) {
if err := identity.ValidateRevisionURLs(revision, builder.config.AppEnv); err != nil {
return nil, err
}
if builder.store == nil || builder.secrets == nil || revision.Issuer == "" || revision.TenantID == "" ||
revision.Audience == "" || revision.RolePrefix == "" {
return nil, errors.New("identity runtime configuration is incomplete")
}
if exists, err := builder.store.HasActiveTenantKey(ctx, revision.LocalTenantKey); err != nil {
return nil, err
} else if !exists {
return nil, identity.ErrLocalTenantInvalid
}
runtimeCtx, cancel := context.WithCancel(builder.ctx)
runtime := &Runtime{Revision: revision, CookieSecure: secureCookieFor(revision.PublicBaseURL), close: cancel}
var securityManager *securityevents.ConnectionManager
if revision.SessionRevocation {
prepared := builder.preparedSecurityRuntime(revision.ID)
securityManager = prepared.manager
if securityManager != nil {
if err := securityManager.ValidateConfiguredConnectionBinding(ctx); err != nil {
cancel()
return nil, err
}
if !prepared.receiverReady {
cancel()
return nil, safeRuntimeError{category: "security_event_not_ready", cause: store.ErrSecurityEventConnectionConflict}
}
}
if securityManager == nil {
var err error
securityManager, err = builder.newSecurityEventManager(runtimeCtx, revision, true)
if err != nil {
cancel()
return nil, err
}
}
runtime.SecurityEvents = securityManager
runtime.securityEventDisconnector = securityManager
}
var evaluator func(context.Context, auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error)
if securityManager != nil {
evaluator = securityManager.Evaluate
}
credentialProvider := func(ctx context.Context) (string, []byte, error) {
if revision.MachineClientID == "" || revision.MachineCredentialRef == "" {
return "", nil, errors.New("managed machine credential is unavailable")
}
secret, err := builder.secrets.Get(ctx, revision.MachineCredentialRef)
return revision.MachineClientID, secret, err
}
verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{
AppEnv: builder.config.AppEnv,
Issuer: revision.Issuer, Audience: revision.Audience, TenantID: revision.TenantID,
RolePrefix: revision.RolePrefix, RequiredScopes: append([]string(nil), revision.Scopes...),
JWKSCacheTTL: builder.config.JWKSCacheTTL, IntrospectionEnabled: revision.TokenIntrospection,
IntrospectionCredentialProvider: credentialProvider, SecurityEventEvaluator: evaluator,
IntrospectionObserver: builder.metrics.ObserveIntrospection,
JWKSRefreshFailureObserver: func() { builder.metrics.ObserveJWKSRefreshFailure("oidc") },
})
if err != nil {
cancel()
return nil, err
}
if err := verifier.ValidateConfiguration(ctx); err != nil {
cancel()
return nil, err
}
runtime.Verifier = verifier
if slices.Contains(revision.Capabilities, "oidc_login") {
if revision.BrowserClientID == "" || revision.SessionEncryptionKeyRef == "" {
cancel()
return nil, errors.New("OIDC browser session configuration is incomplete")
}
key, err := builder.secrets.Get(ctx, revision.SessionEncryptionKeyRef)
if err != nil {
cancel()
return nil, err
}
cipher, err := oidcsession.NewCipher(key)
clear(key)
if err != nil {
cancel()
return nil, err
}
client, err := auth.NewOIDCPublicClient(auth.OIDCPublicClientConfig{
AppEnv: builder.config.AppEnv,
Issuer: revision.Issuer, ClientID: revision.BrowserClientID,
RedirectURI: revision.PublicBaseURL + "/api/v1/auth/oidc/callback",
PostLogoutRedirectURI: revision.WebBaseURL + "/", Scopes: append([]string{"openid", "profile"}, revision.Scopes...),
})
if err != nil {
cancel()
return nil, err
}
if err := client.ValidateConfiguration(ctx); err != nil {
cancel()
return nil, err
}
sessions, err := oidcsession.NewService(builder.store, cipher, verifier, client, oidcsession.Config{
IdleTTL: time.Duration(revision.SessionIdleSeconds) * time.Second,
AbsoluteTTL: time.Duration(revision.SessionAbsoluteSeconds) * time.Second,
RefreshBefore: time.Duration(revision.SessionRefreshSeconds) * time.Second,
})
if err != nil {
cancel()
return nil, err
}
runtime.PublicClient, runtime.Sessions, runtime.SessionCipher = client, sessions, cipher
}
return runtime, nil
}
func (builder *RuntimeBuilder) PrepareSecurityEvents(ctx context.Context, revision identity.Revision, managementSecret []byte) error {
if err := identity.ValidateRevisionURLs(revision, builder.config.AppEnv); err != nil {
return safeRuntimeError{category: "configuration_invalid", cause: err}
}
if !revision.SessionRevocation || revision.SecurityEventIssuer == "" || revision.SecurityEventAudience == "" || revision.MachineClientID == "" {
return errors.New("security event configuration is incomplete")
}
if prepared := builder.preparedSecurityRuntime(revision.ID); prepared.manager != nil && prepared.blockedCategory != "" {
view, err := prepared.manager.Get(ctx)
if err == nil {
category := prepared.blockedCategory
if view.LifecycleStatus == "retiring" || view.LifecycleStatus == "disconnect_pending" {
category = "retirement_pending"
}
return safeRuntimeError{category: category, cause: store.ErrSecurityEventConnectionConflict}
}
if !errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
return safeSecurityEventRuntimeError(err)
}
builder.removePreparedSecurityRuntime(revision.ID, prepared)
}
runtimeCtx, cancel := context.WithCancel(builder.ctx)
manager, err := builder.newSecurityEventManager(runtimeCtx, revision, false)
if err != nil {
cancel()
return safeRuntimeError{category: "configuration_invalid", cause: err}
}
secretCopy := append([]byte(nil), managementSecret...)
_, err = manager.Connect(ctx, revision.SecurityEventIssuer, revision.MachineClientID, secretCopy, "identity-pairing-ssf-"+revision.ID)
clear(secretCopy)
if err != nil {
// A conflicting persisted connection is itself the resource an
// administrator must safely retire. Keep this manager reachable by the
// recovery API even when there is no Active identity Runtime yet.
if errors.Is(err, store.ErrSecurityEventConnectionConflict) {
builder.replacePreparedSecurityRuntime(revision.ID, preparedSecurityRuntime{
manager: manager, cancel: cancel, blockedCategory: safeSecurityEventCategory(err, "connection_conflict"),
})
return safeSecurityEventRuntimeError(err)
}
cancel()
return safeSecurityEventRuntimeError(err)
}
if err := manager.ValidateConfiguredConnectionBinding(ctx); err != nil {
builder.replacePreparedSecurityRuntime(revision.ID, preparedSecurityRuntime{
manager: manager, cancel: cancel, blockedCategory: safeSecurityEventCategory(err, "connection_binding_mismatch"),
})
return safeSecurityEventRuntimeError(err)
}
builder.replacePreparedSecurityRuntime(revision.ID, preparedSecurityRuntime{manager: manager, cancel: cancel, receiverReady: true})
return nil
}
func (builder *RuntimeBuilder) removePreparedSecurityRuntime(revisionID string, prepared preparedSecurityRuntime) {
builder.mutex.Lock()
current := builder.prepared[revisionID]
if current.manager == prepared.manager {
delete(builder.prepared, revisionID)
}
builder.mutex.Unlock()
if prepared.cancel != nil {
prepared.cancel()
}
}
func (builder *RuntimeBuilder) replacePreparedSecurityRuntime(revisionID string, replacement preparedSecurityRuntime) {
builder.mutex.Lock()
previous := builder.prepared[revisionID]
builder.prepared[revisionID] = replacement
builder.mutex.Unlock()
if previous.cancel != nil {
previous.cancel()
}
}
func (builder *RuntimeBuilder) CleanupPreparedSecurityEvents(ctx context.Context, revision identity.Revision) error {
builder.mutex.Lock()
prepared, exists := builder.prepared[revision.ID]
builder.mutex.Unlock()
if !exists {
runtimeCtx, cancel := context.WithCancel(builder.ctx)
manager, err := builder.newSecurityEventManager(runtimeCtx, revision, false)
if err != nil {
cancel()
return safeRuntimeError{category: "configuration_invalid", cause: err}
}
prepared = preparedSecurityRuntime{manager: manager, cancel: cancel}
}
err := prepared.manager.DiscardPreparedConnection(ctx, "identity-pairing-ssf-"+revision.ID)
if err != nil {
builder.mutex.Lock()
if _, alreadyStored := builder.prepared[revision.ID]; !alreadyStored {
builder.prepared[revision.ID] = prepared
}
builder.mutex.Unlock()
return safeSecurityEventRuntimeError(err)
}
builder.mutex.Lock()
current := builder.prepared[revision.ID]
if current.manager == prepared.manager {
delete(builder.prepared, revision.ID)
}
builder.mutex.Unlock()
if prepared.cancel != nil {
prepared.cancel()
}
return nil
}
func (builder *RuntimeBuilder) RetireConflictingSecurityEvents(ctx context.Context, revision identity.Revision) error {
prepared := builder.preparedSecurityRuntime(revision.ID)
if prepared.manager == nil {
runtimeCtx, cancel := context.WithCancel(builder.ctx)
manager, err := builder.newSecurityEventManager(runtimeCtx, revision, false)
if err != nil {
cancel()
return safeRuntimeError{category: "configuration_invalid", cause: err}
}
prepared = preparedSecurityRuntime{manager: manager, cancel: cancel}
builder.replacePreparedSecurityRuntime(revision.ID, prepared)
}
if err := prepared.manager.RetireConflictingConnection(ctx, "identity-pairing-ssf-"+revision.ID); err != nil {
return safeSecurityEventRuntimeError(err)
}
return nil
}
// RecoverSecurityEventDisconnector reconstructs only the SSF management
// surface needed to disable a fail-closed Active Revision. It deliberately
// avoids OIDC discovery, JIT, BFF Session and tenant Runtime construction.
func (builder *RuntimeBuilder) RecoverSecurityEventDisconnector(_ context.Context, revision identity.Revision) (SecurityEventDisconnector, error) {
prepared := builder.preparedSecurityRuntime(revision.ID)
if prepared.manager != nil {
if err := prepared.manager.ValidateConfiguredConnectionBinding(builder.ctx); err != nil {
return nil, err
}
return prepared.manager, nil
}
manager, err := builder.newSecurityEventManager(builder.ctx, revision, true)
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return manager, nil
}
// AdoptPreparedSecurityEvents transfers the prepared manager lifetime to an
// activated Runtime. Validation only peeks at prepared state, so Verification
// callbacks remain available between validation and activation.
func (builder *RuntimeBuilder) AdoptPreparedSecurityEvents(revisionID string) context.CancelFunc {
builder.mutex.Lock()
defer builder.mutex.Unlock()
prepared := builder.prepared[revisionID]
delete(builder.prepared, revisionID)
return prepared.cancel
}
func safeSecurityEventRuntimeError(err error) error {
var categorized interface{ SafeErrorCategory() string }
if errors.As(err, &categorized) {
return err
}
if errors.Is(err, store.ErrSecurityEventConnectionConflict) {
return safeRuntimeError{category: "connection_conflict", cause: err}
}
return safeRuntimeError{category: "preparation_failed", cause: err}
}
func safeSecurityEventCategory(err error, fallback string) string {
var categorized interface{ SafeErrorCategory() string }
if errors.As(err, &categorized) && categorized.SafeErrorCategory() != "" {
return categorized.SafeErrorCategory()
}
return fallback
}
func (builder *RuntimeBuilder) newSecurityEventManager(ctx context.Context, revision identity.Revision, strictRevisionBinding bool) (*securityevents.ConnectionManager, error) {
return securityevents.NewConnectionManager(ctx, builder.store, builder.secrets, builder.securityEventManagerConfig(revision, strictRevisionBinding), builder.metrics)
}
func (builder *RuntimeBuilder) securityEventManagerConfig(revision identity.Revision, strictRevisionBinding bool) securityevents.ConnectionManagerConfig {
return securityevents.ConnectionManagerConfig{
AppEnv: builder.config.AppEnv, OIDCEnabled: true, OIDCIssuer: revision.Issuer, OIDCTenantID: revision.TenantID,
ManagementClientID: revision.MachineClientID, PublicBaseURL: revision.PublicBaseURL,
ExpectedTransmitterIssuer: strings.TrimRight(strings.TrimSpace(revision.SecurityEventIssuer), "/"),
ExpectedAudience: revision.SecurityEventAudience,
ExpectedOwnerKey: "identity-pairing-ssf-" + revision.ID,
StrictRevisionBinding: strictRevisionBinding,
HeartbeatInterval: builder.config.HeartbeatInterval,
StaleAfter: builder.config.StaleAfter,
ClockSkew: builder.config.ClockSkew,
}
}
func (builder *RuntimeBuilder) preparedSecurityRuntime(revisionID string) preparedSecurityRuntime {
builder.mutex.Lock()
defer builder.mutex.Unlock()
return builder.prepared[revisionID]
}
func secureCookieFor(baseURL string) bool {
parsed, err := url.Parse(baseURL)
return err == nil && parsed.Scheme == "https"
}

View File

@ -0,0 +1,81 @@
package identityruntime
import (
"context"
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
)
func TestRuntimeBuilderRejectsLoopbackHTTPOutsideLocalEnvironments(t *testing.T) {
revision := identity.Revision{
AuthCenterURL: "https://auth.example.com", Issuer: "https://auth.example.com/issuer/easyai",
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com",
SecurityEventIssuer: "https://auth.example.com/ssf", SecurityEventConfigURL: "https://auth.example.com/.well-known/ssf-configuration/ssf",
SessionRevocation: true,
}
production := &RuntimeBuilder{config: RuntimeBuilderConfig{AppEnv: "production"}}
for _, test := range []struct {
name string
mutate func(*identity.Revision)
}{
{name: "auth center", mutate: func(value *identity.Revision) { value.AuthCenterURL = "http://localhost:18000" }},
{name: "OIDC issuer", mutate: func(value *identity.Revision) { value.Issuer = "http://localhost:18003/issuer/easyai" }},
{name: "public base", mutate: func(value *identity.Revision) { value.PublicBaseURL = "http://127.0.0.1:18089" }},
{name: "web base", mutate: func(value *identity.Revision) { value.WebBaseURL = "http://localhost:5178" }},
{name: "SSF issuer", mutate: func(value *identity.Revision) { value.SecurityEventIssuer = "http://localhost:18004/ssf" }},
{name: "SSF configuration", mutate: func(value *identity.Revision) {
value.SecurityEventConfigURL = "http://localhost:18004/.well-known/ssf-configuration/ssf"
}},
} {
t.Run(test.name, func(t *testing.T) {
candidate := revision
test.mutate(&candidate)
if _, err := production.Build(context.Background(), candidate); err == nil || !strings.Contains(err.Error(), "HTTPS") {
t.Fatalf("production runtime did not reject loopback HTTP %s URL: %v", test.name, err)
}
})
}
localRevision := revision
localRevision.AuthCenterURL = "http://localhost:18000"
localRevision.Issuer = "http://localhost:18003/issuer/easyai"
localRevision.PublicBaseURL = "http://127.0.0.1:18089"
localRevision.WebBaseURL = "http://localhost:5178"
localRevision.SecurityEventIssuer = "http://localhost:18004/ssf"
localRevision.SecurityEventConfigURL = "http://localhost:18004/.well-known/ssf-configuration/ssf"
development := &RuntimeBuilder{config: RuntimeBuilderConfig{AppEnv: "development"}}
if _, err := development.Build(context.Background(), localRevision); err == nil || strings.Contains(err.Error(), "HTTPS") {
t.Fatalf("development runtime did not pass URL policy before completeness checks: %v", err)
}
}
func TestSecurityEventManagerConfigBindsRuntimeToTargetRevision(t *testing.T) {
builder := &RuntimeBuilder{config: RuntimeBuilderConfig{AppEnv: "test"}}
revision := identity.Revision{
ID: "revision-target",
Issuer: "https://auth.example/issuer",
TenantID: "tenant-public-id",
MachineClientID: "gateway-machine",
PublicBaseURL: "https://gateway.example",
SecurityEventIssuer: "https://auth.example/ssf/",
SecurityEventAudience: "urn:easyai:ssf:receiver:target",
}
runtimeConfig := builder.securityEventManagerConfig(revision, true)
if !runtimeConfig.StrictRevisionBinding ||
runtimeConfig.ExpectedTransmitterIssuer != "https://auth.example/ssf" ||
runtimeConfig.ExpectedAudience != revision.SecurityEventAudience ||
runtimeConfig.ManagementClientID != revision.MachineClientID ||
runtimeConfig.ExpectedOwnerKey != "identity-pairing-ssf-revision-target" {
t.Fatalf("runtime SSF binding config=%#v", runtimeConfig)
}
recoveryConfig := builder.securityEventManagerConfig(revision, false)
if recoveryConfig.StrictRevisionBinding {
t.Fatal("conflict recovery was incorrectly blocked by strict Revision binding")
}
if recoveryConfig.ExpectedOwnerKey != runtimeConfig.ExpectedOwnerKey {
t.Fatal("recovery manager lost the target owner needed for explicit validation")
}
}

View File

@ -0,0 +1,476 @@
package identityruntime
import (
"context"
"errors"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
)
type Repository interface {
IdentityConfigurationRevision(context.Context, string) (identity.Revision, error)
ActiveIdentityConfigurationRevision(context.Context) (identity.Revision, error)
MarkIdentityRevisionValidated(context.Context, string, int64, string, string) (identity.Revision, error)
RevalidateActiveIdentityRevision(context.Context, string, int64, string, string) (identity.Revision, error)
MarkIdentityRevisionFailed(context.Context, string, int64, string, string, string) (identity.Revision, error)
ActivateIdentityRevision(context.Context, string, int64, string, string) (identity.Revision, bool, error)
DisableActiveIdentityRevision(context.Context, int64, string, string) (identity.Revision, error)
}
type Builder interface {
Build(context.Context, identity.Revision) (*Runtime, error)
}
type SecurityEventDisconnector interface {
Disconnect(context.Context) (securityevents.ConnectionView, error)
}
type Runtime struct {
Revision identity.Revision
Verifier *auth.OIDCVerifier
PublicClient *auth.OIDCPublicClient
Sessions *oidcsession.Service
SessionCipher *oidcsession.Cipher
SecurityEvents *securityevents.ConnectionManager
CookieSecure bool
close func()
securityEventDisconnector SecurityEventDisconnector
}
func (runtime *Runtime) Close() {
if runtime != nil && runtime.close != nil {
runtime.close()
}
}
type Manager struct {
repository Repository
builder Builder
operation sync.Mutex
current atomic.Pointer[Runtime]
reconciliationRequired atomic.Bool
legacyJWTAllowed atomic.Bool
trustedWebBaseURL atomic.Pointer[string]
}
const identityRuntimeReconciliationTimeout = 5 * time.Second
func NewManager(repository Repository, builder Builder) *Manager {
manager := &Manager{repository: repository, builder: builder}
manager.legacyJWTAllowed.Store(true)
return manager
}
func (manager *Manager) Current() *Runtime {
return manager.current.Load()
}
func (manager *Manager) ReconciliationRequired() bool {
return manager.reconciliationRequired.Load()
}
func (manager *Manager) LegacyJWTEnabled() bool {
return manager.legacyJWTAllowed.Load()
}
// TrustedWebBaseURL is the persisted Active Revision's exact browser origin.
// It intentionally survives a fail-closed Runtime build so the local
// break-glass manager can still repair or disable a broken Active Revision.
func (manager *Manager) TrustedWebBaseURL() string {
value := manager.trustedWebBaseURL.Load()
if value == nil {
return ""
}
return *value
}
// SecurityEventReceiver resolves the request-time receiver. During first
// onboarding there is no Active Runtime yet, so the builder-owned prepared
// receiver must remain reachable for SSF Verification callbacks.
func (manager *Manager) SecurityEventReceiver() http.Handler {
provider, ok := manager.builder.(interface{ PreparedSecurityEventReceiver() http.Handler })
if ok {
if prepared := provider.PreparedSecurityEventReceiver(); prepared != nil {
return prepared
}
}
if runtime := manager.Current(); runtime != nil && runtime.SecurityEvents != nil {
return runtime.SecurityEvents
}
return manager.SecurityEventManager()
}
// SecurityEventManager resolves the manager used by administrative recovery
// operations. A prepared manager may represent an older persisted connection
// that must be retired before the first identity Runtime can be activated.
func (manager *Manager) SecurityEventManager() *securityevents.ConnectionManager {
if runtime := manager.Current(); runtime != nil && runtime.SecurityEvents != nil {
return runtime.SecurityEvents
}
managerProvider, ok := manager.builder.(interface {
PreparedSecurityEventManager() *securityevents.ConnectionManager
})
if ok {
return managerProvider.PreparedSecurityEventManager()
}
return nil
}
func (manager *Manager) LoadActive(ctx context.Context) error {
manager.operation.Lock()
defer manager.operation.Unlock()
revision, err := manager.repository.ActiveIdentityConfigurationRevision(ctx)
if errors.Is(err, identity.ErrRevisionNotFound) {
manager.publishDisabledRuntime()
return nil
}
if err != nil {
manager.failClosedRuntime()
return err
}
manager.rememberTrustedWebBaseURL(revision.WebBaseURL)
runtime, err := manager.builder.Build(ctx, revision)
if err != nil {
manager.failClosedRuntime()
return err
}
manager.publishRuntime(runtime, revision)
return nil
}
// ReconcileActive restores a fail-closed Runtime after an uncertain mutation
// outcome. It is safe to call periodically: a matching Active Runtime is left
// untouched, while a changed or missing Active Revision is published atomically.
func (manager *Manager) ReconcileActive(ctx context.Context) error {
manager.operation.Lock()
defer manager.operation.Unlock()
revision, err := manager.repository.ActiveIdentityConfigurationRevision(ctx)
if errors.Is(err, identity.ErrRevisionNotFound) {
manager.publishDisabledRuntime()
return nil
}
if err != nil {
manager.reconciliationRequired.Store(true)
return err
}
manager.rememberTrustedWebBaseURL(revision.WebBaseURL)
if current := manager.current.Load(); current != nil && current.Revision.ID == revision.ID && current.Revision.Version == revision.Version {
manager.reconciliationRequired.Store(false)
return nil
}
runtime, err := manager.builder.Build(ctx, revision)
if err != nil {
manager.reconciliationRequired.Store(true)
return err
}
manager.publishRuntime(runtime, revision)
return nil
}
func (manager *Manager) Validate(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
manager.operation.Lock()
defer manager.operation.Unlock()
revision, err := manager.repository.IdentityConfigurationRevision(ctx, id)
if err != nil {
return identity.Revision{}, err
}
if revision.Version != expectedVersion || revision.State != identity.RevisionDraft && revision.State != identity.RevisionActive {
return identity.Revision{}, identity.ErrRevisionConflict
}
candidate, buildErr := manager.builder.Build(ctx, revision)
if buildErr != nil {
if revision.State != identity.RevisionActive {
_, _ = manager.repository.MarkIdentityRevisionFailed(ctx, id, expectedVersion, "validation_failed", traceID, auditID)
}
return identity.Revision{}, buildErr
}
if revision.State == identity.RevisionActive {
revalidated, err := manager.repository.RevalidateActiveIdentityRevision(ctx, id, expectedVersion, traceID, auditID)
if err != nil {
candidate.Close()
return identity.Revision{}, err
}
manager.publishRuntime(candidate, revalidated)
return revalidated, nil
}
candidate.Close()
return manager.repository.MarkIdentityRevisionValidated(ctx, id, expectedVersion, traceID, auditID)
}
func (manager *Manager) Activate(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
manager.operation.Lock()
defer manager.operation.Unlock()
revision, err := manager.repository.IdentityConfigurationRevision(ctx, id)
if err != nil {
return identity.Revision{}, err
}
if revision.Version != expectedVersion || revision.State != identity.RevisionValidated {
return identity.Revision{}, identity.ErrRevisionConflict
}
candidate, err := manager.builder.Build(ctx, revision)
if err != nil {
return identity.Revision{}, err
}
activated, _, err := manager.repository.ActivateIdentityRevision(ctx, id, expectedVersion, traceID, auditID)
if err != nil {
return manager.reconcileActivationMutation(ctx, id, candidate, err)
}
manager.publishRuntime(candidate, activated)
return activated, nil
}
func (manager *Manager) Rollback(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
manager.operation.Lock()
defer manager.operation.Unlock()
revision, err := manager.repository.IdentityConfigurationRevision(ctx, id)
if err != nil {
return identity.Revision{}, err
}
if revision.Version != expectedVersion || revision.State != identity.RevisionSuperseded {
return identity.Revision{}, identity.ErrRevisionConflict
}
// Auth Center currently reuses and mutates OAuth/SSF resources. An older
// local Revision therefore cannot prove that its remote redirect URIs,
// scopes, audiences, or credentials still match. Require a fresh onboarding
// exchange until a versioned remote-resource handoff exists.
return identity.Revision{}, identity.ErrRollbackConfigurationHandoffRequired
}
func (manager *Manager) Disable(ctx context.Context, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
manager.operation.Lock()
defer manager.operation.Unlock()
if err := manager.retireActiveSecurityEventsBeforeDisable(ctx, expectedVersion); err != nil {
return identity.Revision{}, err
}
disabled, err := manager.repository.DisableActiveIdentityRevision(ctx, expectedVersion, traceID, auditID)
if err != nil {
return manager.reconcileDisableMutation(ctx, expectedVersion, err)
}
manager.publishDisabledRuntime()
return disabled, nil
}
func (manager *Manager) retireActiveSecurityEventsBeforeDisable(ctx context.Context, expectedVersion int64) error {
active, err := manager.repository.ActiveIdentityConfigurationRevision(ctx)
if err != nil {
return err
}
if active.Version != expectedVersion {
return identity.ErrRevisionConflict
}
if !active.SessionRevocation {
return nil
}
runtime := manager.current.Load()
var disconnector SecurityEventDisconnector
if runtime != nil && runtime.Revision.ID == active.ID {
disconnector = runtime.securityEventDisconnector
if disconnector == nil && runtime.SecurityEvents != nil {
disconnector = runtime.SecurityEvents
}
}
if disconnector == nil {
recoverer, ok := manager.builder.(interface {
RecoverSecurityEventDisconnector(context.Context, identity.Revision) (SecurityEventDisconnector, error)
})
if !ok {
return identity.ErrSecurityEventRetirementPending
}
var recoverErr error
disconnector, recoverErr = recoverer.RecoverSecurityEventDisconnector(ctx, active)
if recoverErr != nil {
return recoverErr
}
// A missing local connection means there is no bound local Stream or
// credential left to retire. The already-required audit record makes
// this fail-closed recovery decision traceable.
if disconnector == nil {
return nil
}
}
connection, err := disconnector.Disconnect(ctx)
if err != nil {
return err
}
if connection.LifecycleStatus != "retiring" {
return identity.ErrSecurityEventRetirementPending
}
return nil
}
func (manager *Manager) reconcileActivationMutation(ctx context.Context, targetID string, candidate *Runtime, mutationErr error) (identity.Revision, error) {
reconcileCtx, cancel := identityRuntimeReconciliationContext(ctx)
defer cancel()
active, err := manager.repository.ActiveIdentityConfigurationRevision(reconcileCtx)
if err == nil && active.ID == targetID && active.State == identity.RevisionActive {
manager.publishRuntime(candidate, active)
return active, nil
}
candidate.Close()
if errors.Is(err, identity.ErrRevisionNotFound) {
manager.publishDisabledRuntime()
return identity.Revision{}, mutationErr
}
if err != nil {
manager.failClosedRuntime()
return identity.Revision{}, mutationErr
}
current := manager.current.Load()
if current == nil || current.Revision.ID != active.ID {
manager.failClosedRuntime()
}
return identity.Revision{}, mutationErr
}
func (manager *Manager) reconcileRollbackValidation(ctx context.Context, targetID string, expectedVersion int64, mutationErr error) (identity.Revision, error) {
reconcileCtx, cancel := identityRuntimeReconciliationContext(ctx)
defer cancel()
revision, err := manager.repository.IdentityConfigurationRevision(reconcileCtx, targetID)
if err == nil && revision.State == identity.RevisionValidated && revision.Version == expectedVersion+1 {
return revision, nil
}
return identity.Revision{}, mutationErr
}
func (manager *Manager) reconcileDisableMutation(ctx context.Context, expectedVersion int64, mutationErr error) (identity.Revision, error) {
previous := manager.current.Load()
reconcileCtx, cancel := identityRuntimeReconciliationContext(ctx)
defer cancel()
active, err := manager.repository.ActiveIdentityConfigurationRevision(reconcileCtx)
if err == nil {
if previous == nil || active.ID != previous.Revision.ID {
manager.failClosedRuntime()
}
return identity.Revision{}, mutationErr
}
if !errors.Is(err, identity.ErrRevisionNotFound) {
manager.failClosedRuntime()
return identity.Revision{}, mutationErr
}
manager.publishDisabledRuntime()
if previous == nil || previous.Revision.ID == "" {
return identity.Revision{}, mutationErr
}
disabled, lookupErr := manager.repository.IdentityConfigurationRevision(reconcileCtx, previous.Revision.ID)
if lookupErr == nil && disabled.State == identity.RevisionSuperseded && disabled.Version == expectedVersion+1 {
return disabled, nil
}
return identity.Revision{}, mutationErr
}
func identityRuntimeReconciliationContext(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(ctx), identityRuntimeReconciliationTimeout)
}
func (manager *Manager) publishRuntime(candidate *Runtime, revision identity.Revision) {
candidate.Revision = revision
manager.rememberTrustedWebBaseURL(revision.WebBaseURL)
manager.legacyJWTAllowed.Store(revision.LegacyJWTEnabled)
old := manager.current.Swap(candidate)
manager.adoptPreparedSecurityEvents(candidate, revision.ID)
manager.reconciliationRequired.Store(false)
retireRuntime(old)
}
func (manager *Manager) publishDisabledRuntime() {
old := manager.current.Swap(nil)
manager.trustedWebBaseURL.Store(nil)
manager.legacyJWTAllowed.Store(true)
manager.reconciliationRequired.Store(false)
retireRuntime(old)
}
func (manager *Manager) failClosedRuntime() {
manager.legacyJWTAllowed.Store(false)
manager.reconciliationRequired.Store(true)
old := manager.current.Swap(nil)
retireRuntime(old)
}
func (manager *Manager) rememberTrustedWebBaseURL(value string) {
normalized := strings.TrimSpace(value)
if normalized == "" {
manager.trustedWebBaseURL.Store(nil)
return
}
manager.trustedWebBaseURL.Store(&normalized)
}
func (manager *Manager) PrepareSecurityEvents(ctx context.Context, revision identity.Revision, secret []byte) error {
manager.operation.Lock()
defer manager.operation.Unlock()
current, err := manager.repository.IdentityConfigurationRevision(ctx, revision.ID)
if err != nil {
return err
}
if current.State == identity.RevisionActive {
// Activation already crossed the prepared-manager adoption point. A
// stale restart restore must not insert a second Receiver afterwards.
return nil
}
if current.Version != revision.Version || current.State != identity.RevisionDraft && current.State != identity.RevisionValidated {
return identity.ErrRevisionConflict
}
preparer, ok := manager.builder.(interface {
PrepareSecurityEvents(context.Context, identity.Revision, []byte) error
})
if !ok {
return errors.New("security event runtime preparation is unavailable")
}
return preparer.PrepareSecurityEvents(ctx, current, secret)
}
func (manager *Manager) CleanupPreparedSecurityEvents(ctx context.Context, revision identity.Revision) error {
cleaner, ok := manager.builder.(interface {
CleanupPreparedSecurityEvents(context.Context, identity.Revision) error
})
if !ok {
return errors.New("security event runtime cleanup is unavailable")
}
return cleaner.CleanupPreparedSecurityEvents(ctx, revision)
}
func (manager *Manager) RetireConflictingSecurityEvents(ctx context.Context, revision identity.Revision) error {
resolver, ok := manager.builder.(interface {
RetireConflictingSecurityEvents(context.Context, identity.Revision) error
})
if !ok {
return errors.New("security event conflict recovery is unavailable")
}
return resolver.RetireConflictingSecurityEvents(ctx, revision)
}
func (manager *Manager) adoptPreparedSecurityEvents(runtime *Runtime, revisionID string) {
adopter, ok := manager.builder.(interface {
AdoptPreparedSecurityEvents(string) context.CancelFunc
})
if !ok {
return
}
cancel := adopter.AdoptPreparedSecurityEvents(revisionID)
if cancel == nil {
return
}
closeRuntime := runtime.close
runtime.close = func() {
if closeRuntime != nil {
closeRuntime()
}
cancel()
}
}
func retireRuntime(runtime *Runtime) {
if runtime == nil || runtime.close == nil {
return
}
time.AfterFunc(30*time.Second, runtime.Close)
}

View File

@ -0,0 +1,667 @@
package identityruntime
import (
"context"
"errors"
"net/http"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
)
type runtimeRepositoryFake struct {
revisions map[string]identity.Revision
active identity.Revision
activateCalled bool
activeLookupErr error
markValidatedErr error
markValidatedErrAfterApply bool
activateErr error
activateErrAfterApply bool
disableErr error
disableErrAfterApply bool
}
func (f *runtimeRepositoryFake) IdentityConfigurationRevision(_ context.Context, id string) (identity.Revision, error) {
revision, ok := f.revisions[id]
if !ok {
return identity.Revision{}, identity.ErrRevisionNotFound
}
return revision, nil
}
func (f *runtimeRepositoryFake) ActiveIdentityConfigurationRevision(context.Context) (identity.Revision, error) {
if f.activeLookupErr != nil {
return identity.Revision{}, f.activeLookupErr
}
if f.active.ID == "" {
return identity.Revision{}, identity.ErrRevisionNotFound
}
return f.active, nil
}
func (f *runtimeRepositoryFake) MarkIdentityRevisionValidated(_ context.Context, id string, expected int64, _, _ string) (identity.Revision, error) {
if f.markValidatedErr != nil && !f.markValidatedErrAfterApply {
return identity.Revision{}, f.markValidatedErr
}
revision := f.revisions[id]
if revision.Version != expected {
return identity.Revision{}, identity.ErrRevisionConflict
}
revision.State, revision.Version = identity.RevisionValidated, revision.Version+1
f.revisions[id] = revision
if f.markValidatedErr != nil {
return identity.Revision{}, f.markValidatedErr
}
return revision, nil
}
func (f *runtimeRepositoryFake) RevalidateActiveIdentityRevision(_ context.Context, id string, expected int64, traceID, auditID string) (identity.Revision, error) {
revision := f.revisions[id]
if revision.Version != expected || revision.State != identity.RevisionActive {
return identity.Revision{}, identity.ErrRevisionConflict
}
revision.Version, revision.LastTraceID, revision.LastAuditID = revision.Version+1, traceID, auditID
f.revisions[id], f.active = revision, revision
return revision, nil
}
func (f *runtimeRepositoryFake) MarkIdentityRevisionFailed(_ context.Context, id string, expected int64, category, _, _ string) (identity.Revision, error) {
revision := f.revisions[id]
if revision.Version != expected {
return identity.Revision{}, identity.ErrRevisionConflict
}
revision.State, revision.Version, revision.LastErrorCategory = identity.RevisionFailed, revision.Version+1, category
f.revisions[id] = revision
return revision, nil
}
func (f *runtimeRepositoryFake) ActivateIdentityRevision(_ context.Context, id string, expected int64, traceID, auditID string) (identity.Revision, bool, error) {
f.activateCalled = true
if f.activateErr != nil && !f.activateErrAfterApply {
return identity.Revision{}, false, f.activateErr
}
revision := f.revisions[id]
if revision.Version != expected || revision.State != identity.RevisionValidated {
return identity.Revision{}, false, identity.ErrRevisionConflict
}
if f.active.ID != "" {
old := f.active
old.State = identity.RevisionSuperseded
f.revisions[old.ID] = old
}
revision.State, revision.Version, revision.LastTraceID, revision.LastAuditID = identity.RevisionActive, revision.Version+1, traceID, auditID
f.active, f.revisions[id] = revision, revision
if f.activateErr != nil {
return identity.Revision{}, false, f.activateErr
}
return revision, true, nil
}
func (f *runtimeRepositoryFake) DisableActiveIdentityRevision(_ context.Context, expected int64, traceID, auditID string) (identity.Revision, error) {
if f.disableErr != nil && !f.disableErrAfterApply {
return identity.Revision{}, f.disableErr
}
if f.active.Version != expected {
return identity.Revision{}, identity.ErrRevisionConflict
}
disabled := f.active
disabled.State, disabled.Version, disabled.LastTraceID, disabled.LastAuditID = identity.RevisionSuperseded, disabled.Version+1, traceID, auditID
f.revisions[disabled.ID] = disabled
f.active = identity.Revision{}
if f.disableErr != nil {
return identity.Revision{}, f.disableErr
}
return disabled, nil
}
type runtimeBuilderFake struct {
err error
builtIDs []string
buildStarted chan struct{}
buildRelease chan struct{}
prepareStarted chan struct{}
prepareRelease chan struct{}
prepareCalls int
cleanupCalledFor string
adoptedRevision string
preparedCancel context.CancelFunc
preparedManager *securityevents.ConnectionManager
preparedReceiver http.Handler
recoveredSecurityEventDisconnector SecurityEventDisconnector
recoverSecurityEventErr error
}
type runtimeSecurityEventDisconnector struct {
lifecycle string
err error
calls int
}
func (f *runtimeSecurityEventDisconnector) Disconnect(context.Context) (securityevents.ConnectionView, error) {
f.calls++
return securityevents.ConnectionView{LifecycleStatus: f.lifecycle}, f.err
}
func (f *runtimeBuilderFake) Build(_ context.Context, revision identity.Revision) (*Runtime, error) {
f.builtIDs = append(f.builtIDs, revision.ID)
if f.buildStarted != nil {
close(f.buildStarted)
}
if f.buildRelease != nil {
<-f.buildRelease
}
if f.err != nil {
return nil, f.err
}
return &Runtime{Revision: revision}, nil
}
func (f *runtimeBuilderFake) PrepareSecurityEvents(_ context.Context, _ identity.Revision, _ []byte) error {
f.prepareCalls++
if f.prepareStarted != nil {
close(f.prepareStarted)
}
if f.prepareRelease != nil {
<-f.prepareRelease
}
return f.err
}
func (f *runtimeBuilderFake) CleanupPreparedSecurityEvents(_ context.Context, revision identity.Revision) error {
f.cleanupCalledFor = revision.ID
return f.err
}
func (f *runtimeBuilderFake) AdoptPreparedSecurityEvents(revisionID string) context.CancelFunc {
f.adoptedRevision = revisionID
return f.preparedCancel
}
func (f *runtimeBuilderFake) PreparedSecurityEventManager() *securityevents.ConnectionManager {
return f.preparedManager
}
func (f *runtimeBuilderFake) PreparedSecurityEventReceiver() http.Handler {
return f.preparedReceiver
}
func (f *runtimeBuilderFake) RecoverSecurityEventDisconnector(context.Context, identity.Revision) (SecurityEventDisconnector, error) {
return f.recoveredSecurityEventDisconnector, f.recoverSecurityEventErr
}
func TestValidationFailureKeepsCurrentRuntimeAndDoesNotActivate(t *testing.T) {
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4}
draft := identity.Revision{ID: "draft", State: identity.RevisionDraft, Version: 1}
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active, "draft": draft}, active: active}
builder := &runtimeBuilderFake{err: errors.New("discovery failed")}
manager := NewManager(repository, builder)
manager.current.Store(&Runtime{Revision: active})
if _, err := manager.Validate(context.Background(), draft.ID, draft.Version, "trace", "audit"); err == nil {
t.Fatal("validation failure was ignored")
}
if manager.Current().Revision.ID != active.ID || repository.activateCalled {
t.Fatal("validation failure changed current runtime or activated the draft")
}
if repository.revisions[draft.ID].State != identity.RevisionFailed {
t.Fatal("failed draft was not marked failed")
}
}
func TestLoadActiveMarksTransientFailureForAutomaticReconciliation(t *testing.T) {
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4}
repository := &runtimeRepositoryFake{
revisions: map[string]identity.Revision{"active": active},
active: active,
activeLookupErr: errors.New("database temporarily unavailable"),
}
builder := &runtimeBuilderFake{}
manager := NewManager(repository, builder)
if err := manager.LoadActive(context.Background()); err == nil {
t.Fatal("transient active lookup failure was ignored")
}
if !manager.ReconciliationRequired() || manager.Current() != nil {
t.Fatal("failed startup load was not left fail-closed and retryable")
}
repository.activeLookupErr = nil
builder.err = errors.New("SecretStore temporarily unavailable")
if err := manager.ReconcileActive(context.Background()); err == nil {
t.Fatal("transient runtime build failure was ignored")
}
if !manager.ReconciliationRequired() {
t.Fatal("failed runtime build cleared automatic reconciliation")
}
builder.err = nil
if err := manager.ReconcileActive(context.Background()); err != nil {
t.Fatal(err)
}
if manager.ReconciliationRequired() || manager.Current() == nil || manager.Current().Revision.ID != active.ID {
t.Fatalf("startup runtime was not recovered: current=%#v required=%v", manager.Current(), manager.ReconciliationRequired())
}
}
func TestActivationSwapsRuntimeOnlyAfterRepositoryActivation(t *testing.T) {
active := identity.Revision{ID: "old", State: identity.RevisionActive, Version: 2}
candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3}
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"old": active, "new": candidate}, active: active}
manager := NewManager(repository, &runtimeBuilderFake{})
manager.current.Store(&Runtime{Revision: active})
activated, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit")
if err != nil {
t.Fatal(err)
}
if !repository.activateCalled || activated.State != identity.RevisionActive || manager.Current().Revision.ID != candidate.ID {
t.Fatalf("activation order failed: activated=%#v current=%#v", activated, manager.Current())
}
}
func TestActivationReconcilesCommitAppliedThenResponseLost(t *testing.T) {
active := identity.Revision{ID: "old", State: identity.RevisionActive, Version: 2}
candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3}
repository := &runtimeRepositoryFake{
revisions: map[string]identity.Revision{"old": active, "new": candidate},
active: active,
activateErr: errors.New("commit response lost"),
activateErrAfterApply: true,
}
manager := NewManager(repository, &runtimeBuilderFake{})
manager.current.Store(&Runtime{Revision: active})
activated, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit")
if err != nil {
t.Fatal(err)
}
if activated.State != identity.RevisionActive || manager.Current() == nil || manager.Current().Revision.ID != candidate.ID {
t.Fatalf("committed activation was not reconciled: activated=%#v current=%#v", activated, manager.Current())
}
}
func TestActivationKeepsCurrentRuntimeWhenMutationWasNotApplied(t *testing.T) {
active := identity.Revision{ID: "old", State: identity.RevisionActive, Version: 2}
candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3}
mutationErr := errors.New("activation rejected before commit")
repository := &runtimeRepositoryFake{
revisions: map[string]identity.Revision{"old": active, "new": candidate},
active: active,
activateErr: mutationErr,
}
manager := NewManager(repository, &runtimeBuilderFake{})
original := &Runtime{Revision: active}
manager.current.Store(original)
if _, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit"); !errors.Is(err, mutationErr) {
t.Fatalf("activation error = %v, want %v", err, mutationErr)
}
if manager.Current() != original {
t.Fatal("definitely uncommitted activation replaced the current runtime")
}
}
func TestActivationFailsClosedWhenCommitOutcomeCannotBeReconciled(t *testing.T) {
active := identity.Revision{ID: "old", State: identity.RevisionActive, Version: 2}
candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3}
repository := &runtimeRepositoryFake{
revisions: map[string]identity.Revision{"old": active, "new": candidate},
active: active,
activateErr: errors.New("commit outcome unknown"),
activeLookupErr: errors.New("database unavailable during reconciliation"),
}
builder := &runtimeBuilderFake{}
manager := NewManager(repository, builder)
manager.current.Store(&Runtime{Revision: active})
if _, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit"); err == nil {
t.Fatal("unknown activation outcome was reported as successful")
}
if manager.Current() != nil {
t.Fatal("unknown activation outcome did not fail OIDC closed")
}
if !manager.ReconciliationRequired() {
t.Fatal("unknown activation outcome did not request background reconciliation")
}
repository.activeLookupErr = nil
builder.err = errors.New("runtime dependency temporarily unavailable")
if err := manager.ReconcileActive(context.Background()); err == nil {
t.Fatal("runtime reconciliation unexpectedly ignored build failure")
}
if !manager.ReconciliationRequired() || manager.Current() != nil {
t.Fatal("failed runtime rebuild cleared fail-closed reconciliation state")
}
builder.err = nil
if err := manager.ReconcileActive(context.Background()); err != nil {
t.Fatal(err)
}
if manager.ReconciliationRequired() || manager.Current() == nil || manager.Current().Revision.ID != active.ID {
t.Fatalf("runtime did not recover after reconciliation: current=%#v required=%v", manager.Current(), manager.ReconciliationRequired())
}
}
func TestRollbackRejectsOIDCOnlyRevisionBeforeRuntimeBuild(t *testing.T) {
active := identity.Revision{ID: "current", State: identity.RevisionActive, Version: 5}
previous := identity.Revision{ID: "previous", State: identity.RevisionSuperseded, Version: 3}
repository := &runtimeRepositoryFake{
revisions: map[string]identity.Revision{"current": active, "previous": previous}, active: active,
}
builder := &runtimeBuilderFake{}
manager := NewManager(repository, builder)
manager.current.Store(&Runtime{Revision: active})
if _, err := manager.Rollback(context.Background(), previous.ID, previous.Version, "trace", "audit"); !errors.Is(err, identity.ErrRollbackConfigurationHandoffRequired) {
t.Fatalf("rollback error=%v, want remote resource handoff required", err)
}
if len(builder.builtIDs) != 0 || repository.activateCalled || manager.Current() == nil || manager.Current().Revision.ID != active.ID {
t.Fatalf("unsafe rollback changed runtime: built=%v activate=%t current=%#v", builder.builtIDs, repository.activateCalled, manager.Current())
}
}
func TestRollbackRejectsSupersededMachineCredentialBeforeRuntimeBuild(t *testing.T) {
active := identity.Revision{ID: "current", State: identity.RevisionActive, Version: 5}
previous := identity.Revision{
ID: "previous", State: identity.RevisionSuperseded, Version: 3,
MachineCredentialRef: "identity-machine-previous", TokenIntrospection: true,
}
repository := &runtimeRepositoryFake{
revisions: map[string]identity.Revision{"current": active, "previous": previous}, active: active,
}
builder := &runtimeBuilderFake{}
manager := NewManager(repository, builder)
manager.current.Store(&Runtime{Revision: active})
if _, err := manager.Rollback(context.Background(), previous.ID, previous.Version, "trace", "audit"); !errors.Is(err, identity.ErrRollbackConfigurationHandoffRequired) {
t.Fatalf("rollback error=%v, want credential handoff required", err)
}
if len(builder.builtIDs) != 0 || repository.activateCalled {
t.Fatal("unsafe rollback reached runtime build or activation")
}
}
func TestValidateRejectsSupersededRevisionBeforeRuntimeBuild(t *testing.T) {
previous := identity.Revision{ID: "previous", State: identity.RevisionSuperseded, Version: 3}
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"previous": previous}}
builder := &runtimeBuilderFake{}
manager := NewManager(repository, builder)
if _, err := manager.Validate(context.Background(), previous.ID, previous.Version, "trace", "audit"); !errors.Is(err, identity.ErrRevisionConflict) {
t.Fatalf("validate error=%v, want revision conflict", err)
}
if len(builder.builtIDs) != 0 || repository.revisions[previous.ID].State != identity.RevisionSuperseded {
t.Fatalf("superseded revision reached runtime validation: built=%v revision=%#v", builder.builtIDs, repository.revisions[previous.ID])
}
}
func TestDisableKeepsActiveRevisionUntilSecurityEventStreamCanRetire(t *testing.T) {
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4, SessionRevocation: true}
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active}, active: active}
disconnector := &runtimeSecurityEventDisconnector{lifecycle: "disconnect_pending"}
manager := NewManager(repository, &runtimeBuilderFake{})
original := &Runtime{Revision: active, securityEventDisconnector: disconnector}
manager.current.Store(original)
if _, err := manager.Disable(context.Background(), active.Version, "trace", "audit"); !errors.Is(err, identity.ErrSecurityEventRetirementPending) {
t.Fatalf("disable error=%v, want security event retirement pending", err)
}
if disconnector.calls != 1 || repository.active.ID != active.ID || manager.Current() != original {
t.Fatalf("pending retirement changed Active state: calls=%d repository=%#v current=%#v", disconnector.calls, repository.active, manager.Current())
}
}
func TestDisableRecoversSSFWithoutBuildingBrokenFullIdentityRuntime(t *testing.T) {
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4, SessionRevocation: true}
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active}, active: active}
disconnector := &runtimeSecurityEventDisconnector{lifecycle: "retiring"}
builder := &runtimeBuilderFake{
err: errors.New("OIDC discovery unavailable"),
recoveredSecurityEventDisconnector: disconnector,
}
manager := NewManager(repository, builder)
disabled, err := manager.Disable(context.Background(), active.Version, "trace", "audit")
if err != nil {
t.Fatal(err)
}
if disabled.State != identity.RevisionSuperseded || disconnector.calls != 1 || len(builder.builtIDs) != 0 || manager.Current() != nil {
t.Fatalf("SSF-only recovery did not disable safely: disabled=%#v calls=%d built=%v current=%#v", disabled, disconnector.calls, builder.builtIDs, manager.Current())
}
}
func TestDisableRetiresSecurityEventStreamBeforeSupersedingActiveRevision(t *testing.T) {
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4, SessionRevocation: true}
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active}, active: active}
disconnector := &runtimeSecurityEventDisconnector{lifecycle: "retiring"}
manager := NewManager(repository, &runtimeBuilderFake{})
manager.current.Store(&Runtime{Revision: active, securityEventDisconnector: disconnector})
disabled, err := manager.Disable(context.Background(), active.Version, "trace", "audit")
if err != nil {
t.Fatal(err)
}
if disconnector.calls != 1 || disabled.State != identity.RevisionSuperseded || repository.active.ID != "" || manager.Current() != nil {
t.Fatalf("safe disable state: calls=%d disabled=%#v repository=%#v current=%#v", disconnector.calls, disabled, repository.active, manager.Current())
}
}
func TestDisableReconcilesCommitAppliedThenResponseLost(t *testing.T) {
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4}
repository := &runtimeRepositoryFake{
revisions: map[string]identity.Revision{"active": active},
active: active,
disableErr: errors.New("commit response lost"),
disableErrAfterApply: true,
}
manager := NewManager(repository, &runtimeBuilderFake{})
manager.current.Store(&Runtime{Revision: active})
disabled, err := manager.Disable(context.Background(), active.Version, "trace", "audit")
if err != nil {
t.Fatal(err)
}
if disabled.State != identity.RevisionSuperseded || manager.Current() != nil {
t.Fatalf("committed disable was not reconciled: disabled=%#v current=%#v", disabled, manager.Current())
}
}
func TestActiveRevalidationSwapsOnlyAfterSuccessfulValidation(t *testing.T) {
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4}
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active}, active: active}
builder := &runtimeBuilderFake{}
manager := NewManager(repository, builder)
original := &Runtime{Revision: active}
manager.current.Store(original)
revalidated, err := manager.Validate(context.Background(), active.ID, active.Version, "trace-new", "audit-new")
if err != nil {
t.Fatal(err)
}
if revalidated.State != identity.RevisionActive || revalidated.Version != 5 || manager.Current() == original || manager.Current().Revision.LastAuditID != "audit-new" {
t.Fatalf("active runtime was not safely revalidated: %#v", revalidated)
}
}
func TestLoadFailureKeepsPersistedActiveWebOriginForBreakGlassRecovery(t *testing.T) {
active := identity.Revision{
ID: "active", State: identity.RevisionActive, Version: 4,
WebBaseURL: "https://gateway.example.com",
}
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active}, active: active}
manager := NewManager(repository, &runtimeBuilderFake{err: errors.New("OIDC discovery unavailable")})
if err := manager.LoadActive(context.Background()); err == nil {
t.Fatal("active runtime load unexpectedly succeeded")
}
if manager.Current() != nil || manager.TrustedWebBaseURL() != active.WebBaseURL {
t.Fatalf("failed Active load lost recovery origin: current=%#v origin=%q", manager.Current(), manager.TrustedWebBaseURL())
}
repository.active = identity.Revision{}
if err := manager.ReconcileActive(context.Background()); err != nil {
t.Fatal(err)
}
if manager.TrustedWebBaseURL() != "" {
t.Fatalf("confirmed disable retained stale recovery origin %q", manager.TrustedWebBaseURL())
}
}
func TestActivationFinishingFirstPreventsStalePreparedSecurityEventRestore(t *testing.T) {
candidate := identity.Revision{ID: "candidate", State: identity.RevisionValidated, Version: 3}
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"candidate": candidate}}
builder := &runtimeBuilderFake{buildStarted: make(chan struct{}), buildRelease: make(chan struct{})}
manager := NewManager(repository, builder)
activationDone := make(chan error, 1)
go func() {
_, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit")
activationDone <- err
}()
<-builder.buildStarted
restoreDone := make(chan error, 1)
go func() {
restoreDone <- manager.PrepareSecurityEvents(context.Background(), candidate, []byte("secret"))
}()
close(builder.buildRelease)
if err := <-activationDone; err != nil {
t.Fatal(err)
}
if err := <-restoreDone; err != nil {
t.Fatal(err)
}
if builder.prepareCalls != 0 || manager.Current() == nil || manager.Current().Revision.ID != candidate.ID {
t.Fatalf("stale restore survived activation: prepare_calls=%d current=%#v", builder.prepareCalls, manager.Current())
}
}
func TestPreparedSecurityEventRestoreFinishingFirstIsAdoptedByActivation(t *testing.T) {
candidate := identity.Revision{ID: "candidate", State: identity.RevisionValidated, Version: 3}
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"candidate": candidate}}
builder := &runtimeBuilderFake{
buildStarted: make(chan struct{}), buildRelease: make(chan struct{}),
prepareStarted: make(chan struct{}), prepareRelease: make(chan struct{}),
}
manager := NewManager(repository, builder)
restoreDone := make(chan error, 1)
go func() {
restoreDone <- manager.PrepareSecurityEvents(context.Background(), candidate, []byte("secret"))
}()
<-builder.prepareStarted
activationDone := make(chan error, 1)
go func() {
_, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit")
activationDone <- err
}()
select {
case <-builder.buildStarted:
t.Fatal("activation did not wait for prepared Receiver restoration")
case <-time.After(50 * time.Millisecond):
}
close(builder.prepareRelease)
if err := <-restoreDone; err != nil {
t.Fatal(err)
}
<-builder.buildStarted
close(builder.buildRelease)
if err := <-activationDone; err != nil {
t.Fatal(err)
}
if builder.prepareCalls != 1 || builder.adoptedRevision != candidate.ID {
t.Fatalf("prepared restore was not adopted: prepare_calls=%d adopted=%q", builder.prepareCalls, builder.adoptedRevision)
}
}
func TestManagerDelegatesPreparedSecurityEventCleanupWithoutChangingActiveRuntime(t *testing.T) {
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4}
draft := identity.Revision{ID: "draft", State: identity.RevisionFailed, Version: 2}
builder := &runtimeBuilderFake{}
manager := NewManager(&runtimeRepositoryFake{}, builder)
manager.current.Store(&Runtime{Revision: active})
if err := manager.CleanupPreparedSecurityEvents(context.Background(), draft); err != nil {
t.Fatal(err)
}
if builder.cleanupCalledFor != draft.ID || manager.Current().Revision.ID != active.ID {
t.Fatalf("cleanup changed active runtime or skipped the draft: called=%q current=%#v", builder.cleanupCalledFor, manager.Current())
}
}
func TestActivationAdoptsPreparedSecurityEventLifetime(t *testing.T) {
candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3}
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"new": candidate}}
preparedContext, preparedCancel := context.WithCancel(context.Background())
builder := &runtimeBuilderFake{preparedCancel: preparedCancel}
manager := NewManager(repository, builder)
if _, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit"); err != nil {
t.Fatal(err)
}
if builder.adoptedRevision != candidate.ID {
t.Fatalf("prepared security events were not adopted: %q", builder.adoptedRevision)
}
manager.Current().Close()
select {
case <-preparedContext.Done():
default:
t.Fatal("closing the active runtime did not release its prepared security event manager")
}
}
func TestPreparedSecurityEventReceiverRemainsAvailableUntilAdoption(t *testing.T) {
cancelled := false
builder := &RuntimeBuilder{prepared: map[string]preparedSecurityRuntime{
"draft": {manager: &securityevents.ConnectionManager{}, cancel: func() { cancelled = true }, receiverReady: true},
}}
if builder.PreparedSecurityEventReceiver() == nil {
t.Fatal("prepared receiver was unavailable before activation")
}
cancel := builder.AdoptPreparedSecurityEvents("draft")
if cancel == nil || builder.PreparedSecurityEventReceiver() != nil {
t.Fatal("prepared receiver ownership was not transferred exactly once")
}
cancel()
if !cancelled {
t.Fatal("adopted receiver lifetime could not be released")
}
}
func TestSecurityEventManagerExposesPreparedRecoveryManagerWithoutActiveRuntime(t *testing.T) {
prepared := &securityevents.ConnectionManager{}
manager := NewManager(&runtimeRepositoryFake{}, &runtimeBuilderFake{preparedManager: prepared})
if manager.SecurityEventManager() != prepared || manager.SecurityEventReceiver() != prepared {
t.Fatal("prepared security event manager was unavailable to recovery handlers")
}
}
func TestSecurityEventManagerPrefersActiveRuntime(t *testing.T) {
active := &securityevents.ConnectionManager{}
prepared := &securityevents.ConnectionManager{}
manager := NewManager(&runtimeRepositoryFake{}, &runtimeBuilderFake{preparedManager: prepared})
manager.current.Store(&Runtime{SecurityEvents: active})
if manager.SecurityEventManager() != active {
t.Fatal("prepared recovery manager replaced the active runtime manager")
}
}
func TestSecurityEventReceiverPrefersReadyDraftDuringRepairing(t *testing.T) {
active := &securityevents.ConnectionManager{}
prepared := &securityevents.ConnectionManager{}
manager := NewManager(&runtimeRepositoryFake{}, &runtimeBuilderFake{preparedManager: prepared, preparedReceiver: prepared})
manager.current.Store(&Runtime{SecurityEvents: active})
if manager.SecurityEventReceiver() != prepared {
t.Fatal("verification callback was not routed to the ready draft receiver")
}
}
func TestConflictingPreparedManagerIsRecoverableButNotUsedAsReceiver(t *testing.T) {
prepared := &securityevents.ConnectionManager{}
builder := &RuntimeBuilder{prepared: map[string]preparedSecurityRuntime{
"draft": {manager: prepared},
}}
if builder.PreparedSecurityEventManager() != prepared {
t.Fatal("conflicting connection manager was unavailable for retirement")
}
if builder.PreparedSecurityEventReceiver() != nil {
t.Fatal("unprepared conflict manager was exposed as a verification receiver")
}
}

View File

@ -20,6 +20,7 @@ var (
ErrSessionRefreshUnavailable = errors.New("OIDC session refresh is unavailable")
ErrSessionStoreUnavailable = errors.New("OIDC session store is unavailable")
ErrGatewayUserDisabled = errors.New("gateway user is disabled")
ErrSecurityStateUnavailable = errors.New("OIDC security event state is unavailable")
)
type Repository interface {
@ -179,11 +180,17 @@ func (s *Service) refresh(ctx context.Context, hash []byte, record store.OIDCSes
}
return user, nil
}
if errors.Is(verifyErr, ErrSecurityStateUnavailable) {
return nil, verifyErr
}
}
return nil, ErrSessionRefreshUnavailable
}
user, err := s.verifySessionUser(ctx, record, refreshed.AccessToken)
if err != nil {
if errors.Is(err, ErrSecurityStateUnavailable) {
return nil, err
}
_ = s.repository.DeleteOIDCSessionByID(ctx, record.ID)
return nil, ErrSessionExpired
}
@ -241,12 +248,19 @@ func (s *Service) waitForRefresh(ctx context.Context, hash []byte, original stor
}
return user, nil
}
if errors.Is(err, ErrSecurityStateUnavailable) {
return nil, err
}
}
return nil, ErrSessionRefreshUnavailable
}
func (s *Service) verifySessionUser(ctx context.Context, record store.OIDCSession, accessToken string) (*auth.User, error) {
user, err := s.verifier.Verify(ctx, accessToken)
var requestError *auth.RequestAuthError
if errors.As(err, &requestError) && requestError.Status == 503 {
return nil, ErrSecurityStateUnavailable
}
if err != nil || user == nil || user.Source != "oidc" || user.ID != record.ExternalUserID {
return nil, ErrSessionInvalid
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,185 @@
package securityevents
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type Repository interface {
ApplySessionRevoked(context.Context, store.ApplySessionRevokedInput) (store.ApplySecurityEventResult, error)
ApplySecurityEventStreamUpdated(context.Context, string, string, string, string, string, string, time.Time) (bool, error)
ConfirmSecurityEventVerification(context.Context, string, string, string, string, []byte, time.Time) (bool, error)
RecordSecurityEventReceipt(context.Context, string, string, string, string, string) (bool, error)
}
type Handler struct {
verifier *Verifier
repository Repository
issuer string
audience string
streamID string
bearerSecret string
nextSecret string
clock func() time.Time
metrics *Metrics
}
func (h *Handler) SetMetrics(metrics *Metrics) { h.metrics = metrics }
func NewHandler(verifier *Verifier, repository Repository, issuer, audience, streamID, bearerSecret, nextSecret string) (*Handler, error) {
if verifier == nil || repository == nil || issuer == "" || audience == "" || streamID == "" || len(bearerSecret) < 16 {
return nil, errors.New("SSF receiver dependencies and bearer secret are required")
}
return &Handler{verifier: verifier, repository: repository, issuer: issuer, audience: audience, streamID: streamID, bearerSecret: bearerSecret, nextSecret: nextSecret, clock: time.Now}, nil
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
started := h.clock()
outcome := "rejected"
var sessionsDeleted int64
defer func() {
if h.metrics != nil {
h.metrics.ObserveReceipt(outcome, h.clock().Sub(started), sessionsDeleted)
}
}()
w.Header().Set("Cache-Control", "no-store")
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if !h.validAuthorization(r.Header.Get("Authorization")) {
w.Header().Set("WWW-Authenticate", `Bearer realm="ssf-receiver"`)
writeSETError(w, http.StatusUnauthorized, "authentication_failed")
return
}
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || mediaType != "application/secevent+jwt" || r.Header.Get("Content-Encoding") != "" {
writeSETError(w, http.StatusBadRequest, "invalid_request")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
payload, err := io.ReadAll(r.Body)
if err != nil || len(payload) == 0 {
writeSETError(w, http.StatusBadRequest, "invalid_request")
return
}
event, err := h.verifier.Verify(r.Context(), string(payload))
if err != nil {
var protocol *protocolError
if errors.As(err, &protocol) {
writeSETError(w, http.StatusBadRequest, protocol.Code)
} else {
writeSETError(w, http.StatusBadRequest, "invalid_request")
}
return
}
switch event.EventType {
case SessionRevokedEventType:
var result store.ApplySecurityEventResult
result, err = h.repository.ApplySessionRevoked(r.Context(), store.ApplySessionRevokedInput{
Issuer: event.Issuer, Audience: event.Audience, JTI: event.JTI, TransactionID: event.TransactionID,
SubjectIssuer: event.SubjectIssuer, TenantID: event.TenantID, Subject: event.Subject, EventTimestamp: event.EventTimestamp,
InitiatingEntity: event.InitiatingEntity,
})
if err == nil {
sessionsDeleted = result.SessionsDeleted
if result.Duplicate {
outcome = "duplicate"
} else {
outcome = "accepted"
}
}
case VerificationEventType:
var confirmed bool
if event.State == "" {
confirmed, err = h.repository.RecordSecurityEventReceipt(r.Context(), h.issuer, h.audience, event.JTI, VerificationEventType, h.streamID)
} else {
stateHash := sha256.Sum256([]byte(event.State))
confirmed, err = h.repository.ConfirmSecurityEventVerification(r.Context(), h.issuer, h.audience, h.streamID, event.JTI, stateHash[:], h.clock().UTC())
}
if err != nil && strings.Contains(err.Error(), "invalid verification state") {
writeSETError(w, http.StatusBadRequest, "invalid_request")
return
}
if err == nil {
if confirmed {
outcome = "accepted"
if h.metrics != nil {
h.metrics.ObserveVerificationAccepted()
}
} else {
outcome = "duplicate"
}
}
case StreamUpdatedEventType:
var inserted bool
inserted, err = h.repository.ApplySecurityEventStreamUpdated(
r.Context(), h.issuer, h.audience, event.JTI, h.streamID, event.StreamStatus, event.Reason, h.clock().UTC(),
)
if err == nil {
if inserted {
outcome = "accepted"
} else {
outcome = "duplicate"
}
}
}
if err != nil {
w.Header().Set("Retry-After", "1")
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusAccepted)
}
func (h *Handler) validAuthorization(value string) bool {
if constantBearerCompare(value, h.bearerSecret) {
return true
}
return h.nextSecret != "" && constantBearerCompare(value, h.nextSecret)
}
func constantBearerCompare(header, secret string) bool {
providedHash := sha256.Sum256([]byte(header))
expectedHash := sha256.Sum256([]byte("Bearer " + secret))
return subtle.ConstantTimeCompare(providedHash[:], expectedHash[:]) == 1
}
func writeSETError(w http.ResponseWriter, status int, code string) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Language", "en")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{
"err": code,
"description": setErrorDescription(code),
})
}
func setErrorDescription(code string) string {
switch code {
case "invalid_request":
return "The SET request is malformed or contains invalid claims."
case "invalid_key":
return "The SET signing key or signature is invalid."
case "invalid_issuer":
return "The SET issuer is not accepted by this receiver."
case "invalid_audience":
return "The SET audience does not identify this receiver."
case "authentication_failed":
return "Receiver authentication failed."
case "access_denied":
return "The SET is not permitted for this receiver."
default:
return "The SET request could not be processed."
}
}

View File

@ -0,0 +1,269 @@
package securityevents
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
type fakeRepository struct {
input *store.ApplySessionRevokedInput
dedup map[string]struct{}
confirmed bool
}
func (f *fakeRepository) ApplySessionRevoked(_ context.Context, input store.ApplySessionRevokedInput) (store.ApplySecurityEventResult, error) {
if f.dedup == nil {
f.dedup = make(map[string]struct{})
}
if _, exists := f.dedup[input.JTI]; exists {
return store.ApplySecurityEventResult{Duplicate: true}, nil
}
f.dedup[input.JTI] = struct{}{}
f.input = &input
return store.ApplySecurityEventResult{WatermarkMoved: true, SessionsDeleted: 2}, nil
}
func (f *fakeRepository) ConfirmSecurityEventVerification(context.Context, string, string, string, string, []byte, time.Time) (bool, error) {
f.confirmed = true
return true, nil
}
func (*fakeRepository) RecordSecurityEventReceipt(context.Context, string, string, string, string, string) (bool, error) {
return true, nil
}
func (*fakeRepository) ApplySecurityEventStreamUpdated(context.Context, string, string, string, string, string, string, time.Time) (bool, error) {
return true, nil
}
func TestReceiverAcceptsValidSessionRevokedAndDuplicate(t *testing.T) {
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
var transmitter *httptest.Server
transmitter = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/ssf-configuration/ssf":
_ = json.NewEncoder(w).Encode(map[string]any{"issuer": transmitter.URL + "/ssf", "jwks_uri": transmitter.URL + "/ssf/jwks.json"})
case "/ssf/jwks.json":
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{map[string]any{
"kty": "EC", "kid": "current", "use": "sig", "alg": "ES256", "crv": "P-256",
"x": coordinate(privateKey.X), "y": coordinate(privateKey.Y),
}}})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer transmitter.Close()
tenantID, subjectID, applicationID, streamID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
audience := "urn:easyai:ssf:receiver:" + applicationID
verifier, err := NewVerifier(VerifierConfig{
TransmitterIssuer: transmitter.URL + "/ssf", Audience: audience,
SubjectIssuer: "https://auth.example/issuer/tenant-a", TenantID: tenantID, StreamID: streamID,
ClockSkew: time.Minute,
})
if err != nil {
t.Fatal(err)
}
repository := &fakeRepository{}
handler, err := NewHandler(verifier, repository, transmitter.URL+"/ssf", audience, streamID, "receiver-bearer-secret", "next-receiver-secret")
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC().Truncate(time.Second)
jti := uuid.NewString()
set := signSET(t, privateKey, map[string]any{
"iss": transmitter.URL + "/ssf", "aud": audience, "iat": now.Unix(), "jti": jti, "txn": uuid.NewString(),
"sub_id": map[string]any{
"format": "complex",
"user": map[string]any{"format": "iss_sub", "iss": "https://auth.example/issuer/tenant-a", "sub": subjectID},
"tenant": map[string]any{"format": "opaque", "id": tenantID},
},
"events": map[string]any{SessionRevokedEventType: map[string]any{"event_timestamp": now.Unix(), "initiating_entity": "admin"}},
})
for _, secret := range []string{"receiver-bearer-secret", "next-receiver-secret"} {
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/security-events/ssf", strings.NewReader(set))
request.Header.Set("Authorization", "Bearer "+secret)
request.Header.Set("Content-Type", "application/secevent+jwt")
handler.ServeHTTP(response, request)
if response.Code != http.StatusAccepted || response.Body.Len() != 0 {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
}
if repository.input == nil || repository.input.Subject != subjectID || repository.input.TenantID != tenantID ||
repository.input.SubjectIssuer != "https://auth.example/issuer/tenant-a" {
t.Fatalf("applied event = %#v", repository.input)
}
}
func TestReceiverRejectsAuthenticationMediaTypeAndForbiddenClaims(t *testing.T) {
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
verifier := &Verifier{config: VerifierConfig{
TransmitterIssuer: "https://auth.example/ssf", Audience: "urn:easyai:ssf:receiver:app",
SubjectIssuer: "https://auth.example/issuer/tenant", TenantID: uuid.NewString(), StreamID: uuid.NewString(), ClockSkew: time.Minute,
}, keys: map[string]*ecdsa.PublicKey{"current": &privateKey.PublicKey}, expiresAt: time.Now().Add(time.Hour), client: http.DefaultClient}
handler, _ := NewHandler(verifier, &fakeRepository{}, "https://auth.example/ssf", verifier.config.Audience, verifier.config.StreamID, "receiver-bearer-secret", "")
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/events", strings.NewReader("token"))
request.Header.Set("Content-Type", "application/secevent+jwt")
handler.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("missing bearer status=%d", response.Code)
}
assertSETError(t, response, "authentication_failed")
set := signSET(t, privateKey, map[string]any{
"iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": time.Now().Unix(),
"jti": uuid.NewString(), "sub": "forbidden", "exp": time.Now().Add(time.Hour).Unix(), "events": map[string]any{},
})
response = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(set))
request.Header.Set("Authorization", "Bearer receiver-bearer-secret")
request.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("wrong media status=%d", response.Code)
}
assertSETError(t, response, "invalid_request")
response = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(set))
request.Header.Set("Authorization", "Bearer receiver-bearer-secret")
request.Header.Set("Content-Type", "application/secevent+jwt")
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "invalid_request") {
t.Fatalf("forbidden claims status=%d body=%s", response.Code, response.Body.String())
}
future := time.Now().Add(10 * time.Minute)
futureSET := signSET(t, privateKey, map[string]any{
"iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": time.Now().Unix(),
"jti": uuid.NewString(), "txn": uuid.NewString(),
"sub_id": map[string]any{
"format": "complex",
"user": map[string]any{"format": "iss_sub", "iss": verifier.config.SubjectIssuer, "sub": uuid.NewString()},
"tenant": map[string]any{"format": "opaque", "id": verifier.config.TenantID},
},
"events": map[string]any{SessionRevokedEventType: map[string]any{
"event_timestamp": future.Unix(), "initiating_entity": "admin",
}},
})
response = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(futureSET))
request.Header.Set("Authorization", "Bearer receiver-bearer-secret")
request.Header.Set("Content-Type", "application/secevent+jwt")
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("future event timestamp status=%d", response.Code)
}
}
func TestReceiverReturnsRFC8935IssuerAudienceAndKeyErrors(t *testing.T) {
trustedKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
untrustedKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
verifier := &Verifier{config: VerifierConfig{
TransmitterIssuer: "https://auth.example/ssf", Audience: "urn:easyai:ssf:receiver:app",
SubjectIssuer: "https://auth.example/issuer/tenant", TenantID: uuid.NewString(), StreamID: uuid.NewString(), ClockSkew: time.Minute,
}, keys: map[string]*ecdsa.PublicKey{"current": &trustedKey.PublicKey}, expiresAt: time.Now().Add(time.Hour), client: http.DefaultClient}
handler, _ := NewHandler(verifier, &fakeRepository{}, verifier.config.TransmitterIssuer, verifier.config.Audience, verifier.config.StreamID, "receiver-bearer-secret", "")
baseClaims := jwt.MapClaims{
"iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": time.Now().Unix(),
"jti": uuid.NewString(), "events": map[string]any{},
}
tests := []struct {
name string
key *ecdsa.PrivateKey
mutate func(jwt.MapClaims)
code string
}{
{name: "issuer", key: trustedKey, mutate: func(claims jwt.MapClaims) { claims["iss"] = "https://wrong.example/ssf" }, code: "invalid_issuer"},
{name: "audience", key: trustedKey, mutate: func(claims jwt.MapClaims) { claims["aud"] = "urn:wrong" }, code: "invalid_audience"},
{name: "signature", key: untrustedKey, mutate: func(jwt.MapClaims) {}, code: "invalid_key"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
claims := jwt.MapClaims{}
for key, value := range baseClaims {
claims[key] = value
}
test.mutate(claims)
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(signSET(t, test.key, claims)))
request.Header.Set("Authorization", "Bearer receiver-bearer-secret")
request.Header.Set("Content-Type", "application/secevent+jwt")
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
assertSETError(t, response, test.code)
})
}
}
func TestReceiverAcceptsStreamUpdatedEvent(t *testing.T) {
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
verifier := &Verifier{config: VerifierConfig{
TransmitterIssuer: "https://auth.example/ssf", Audience: "urn:easyai:ssf:receiver:app",
SubjectIssuer: "https://auth.example/issuer/tenant", TenantID: uuid.NewString(), StreamID: uuid.NewString(), ClockSkew: time.Minute,
}, keys: map[string]*ecdsa.PublicKey{"current": &privateKey.PublicKey}, expiresAt: time.Now().Add(time.Hour), client: http.DefaultClient}
handler, _ := NewHandler(verifier, &fakeRepository{}, verifier.config.TransmitterIssuer, verifier.config.Audience, verifier.config.StreamID, "receiver-bearer-secret", "")
set := signSET(t, privateKey, jwt.MapClaims{
"iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": time.Now().Unix(), "jti": uuid.NewString(),
"sub_id": map[string]any{"format": "opaque", "id": verifier.config.StreamID},
"events": map[string]any{StreamUpdatedEventType: map[string]any{"status": "paused", "reason": "maintenance"}},
})
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(set))
request.Header.Set("Authorization", "Bearer receiver-bearer-secret")
request.Header.Set("Content-Type", "application/secevent+jwt")
handler.ServeHTTP(response, request)
if response.Code != http.StatusAccepted || response.Body.Len() != 0 {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
}
func assertSETError(t *testing.T, response *httptest.ResponseRecorder, code string) {
t.Helper()
if response.Header().Get("Content-Language") != "en" {
t.Fatalf("Content-Language=%q", response.Header().Get("Content-Language"))
}
var payload map[string]string
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("invalid error body: %v", err)
}
if payload["err"] != code || payload["description"] == "" {
t.Fatalf("error payload=%#v", payload)
}
}
func signSET(t *testing.T, key *ecdsa.PrivateKey, claims jwt.MapClaims) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["typ"], token.Header["kid"] = "secevent+jwt", "current"
raw, err := token.SignedString(key)
if err != nil {
t.Fatal(err)
}
return raw
}
func coordinate(value *big.Int) string {
payload := make([]byte, 32)
value.FillBytes(payload)
return base64.RawURLEncoding.EncodeToString(payload)
}

View File

@ -0,0 +1,52 @@
package securityevents
import (
"context"
"errors"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
const (
identitySecretCleanupInterval = time.Minute
identitySecretCleanupLease = 5 * time.Minute
identitySecretCleanupBatch = 100
)
type IdentitySecretCleanupRepository interface {
ClaimIdentitySecretCleanups(context.Context, int, time.Duration) ([]store.IdentitySecretCleanupClaim, error)
CompleteIdentitySecretCleanup(context.Context, string, string) (bool, error)
}
// RunIdentitySecretCleanupWorker owns cleanup at the server lifecycle rather
// than at any individual Active or Prepared identity Runtime. PostgreSQL claim
// tokens make running one worker per server replica safe.
func RunIdentitySecretCleanupWorker(ctx context.Context, repository IdentitySecretCleanupRepository, secrets SecretStore) {
if repository == nil || secrets == nil {
return
}
ticker := time.NewTicker(identitySecretCleanupInterval)
defer ticker.Stop()
for {
cleanupClaimedIdentitySecrets(ctx, repository, secrets)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func cleanupClaimedIdentitySecrets(ctx context.Context, repository IdentitySecretCleanupRepository, secrets SecretStore) {
claims, err := repository.ClaimIdentitySecretCleanups(ctx, identitySecretCleanupBatch, identitySecretCleanupLease)
if err != nil {
return
}
for _, claim := range claims {
if err := secrets.Delete(ctx, claim.Reference); err != nil && !errors.Is(err, ErrSecretNotFound) {
continue
}
_, _ = repository.CompleteIdentitySecretCleanup(ctx, claim.Reference, claim.ClaimToken)
}
}

View File

@ -0,0 +1,186 @@
package securityevents
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
)
const (
defaultKubernetesAPIServer = "https://kubernetes.default.svc"
defaultServiceAccountToken = "/var/run/secrets/kubernetes.io/serviceaccount/token"
defaultServiceAccountCA = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
)
var kubernetesDNSNamePattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$`)
type KubernetesSecretStoreConfig struct {
Namespace string
SecretName string
APIServer string
TokenFile string
CAFile string
HTTPClient *http.Client
}
// KubernetesSecretStore keeps all generated Push Bearers as keys in one
// pre-provisioned Secret. It intentionally has no permission to create or
// delete Kubernetes Secret resources.
type KubernetesSecretStore struct {
endpoint string
tokenFile string
client *http.Client
}
func NewKubernetesSecretStore(config KubernetesSecretStoreConfig) (*KubernetesSecretStore, error) {
if !validKubernetesDNSName(config.Namespace) || !validKubernetesDNSName(config.SecretName) {
return nil, errors.New("Kubernetes security event Secret namespace or name is invalid")
}
if config.APIServer == "" {
config.APIServer = defaultKubernetesAPIServer
}
if config.TokenFile == "" {
config.TokenFile = defaultServiceAccountToken
}
parsed, err := url.Parse(strings.TrimRight(config.APIServer, "/"))
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" ||
(parsed.Scheme != "https" && config.HTTPClient == nil) {
return nil, errors.New("Kubernetes API server URL is invalid")
}
client := config.HTTPClient
if client == nil {
if config.CAFile == "" {
config.CAFile = defaultServiceAccountCA
}
certificate, readErr := os.ReadFile(config.CAFile)
if readErr != nil {
return nil, fmt.Errorf("read Kubernetes service account CA: %w", readErr)
}
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM(certificate) {
return nil, errors.New("Kubernetes service account CA is invalid")
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
transport.DisableCompression = true
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots}
client = &http.Client{Timeout: 10 * time.Second, Transport: transport,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
}
endpoint := fmt.Sprintf("%s/api/v1/namespaces/%s/secrets/%s", strings.TrimRight(config.APIServer, "/"), config.Namespace, config.SecretName)
return &KubernetesSecretStore{endpoint: endpoint, tokenFile: config.TokenFile, client: client}, nil
}
func (s *KubernetesSecretStore) Put(ctx context.Context, reference string, value []byte) error {
if !secretReferencePattern.MatchString(reference) {
return errors.New("security event secret reference is invalid")
}
if len(value) < 32 || len(value) > 4096 {
return errors.New("security event secret length is invalid")
}
return s.patch(ctx, map[string]any{reference: base64.StdEncoding.EncodeToString(value)})
}
func (s *KubernetesSecretStore) Get(ctx context.Context, reference string) ([]byte, error) {
if !secretReferencePattern.MatchString(reference) {
return nil, errors.New("security event secret reference is invalid")
}
request, err := s.request(ctx, http.MethodGet, nil)
if err != nil {
return nil, err
}
response, err := s.client.Do(request)
if err != nil {
return nil, fmt.Errorf("read Kubernetes security event Secret: %w", err)
}
defer response.Body.Close()
if response.StatusCode == http.StatusNotFound {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
return nil, ErrSecretNotFound
}
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
return nil, fmt.Errorf("Kubernetes security event Secret returned HTTP %d", response.StatusCode)
}
var payload struct {
Data map[string]string `json:"data"`
}
if err := decodeLimitedJSON(response.Body, &payload); err != nil {
return nil, err
}
encoded, ok := payload.Data[reference]
if !ok {
return nil, ErrSecretNotFound
}
value, err := base64.StdEncoding.DecodeString(encoded)
if err != nil || len(value) < 32 || len(value) > 4096 {
clear(value)
return nil, errors.New("Kubernetes security event Secret value is invalid")
}
return value, nil
}
func (s *KubernetesSecretStore) Delete(ctx context.Context, reference string) error {
if !secretReferencePattern.MatchString(reference) {
return errors.New("security event secret reference is invalid")
}
return s.patch(ctx, map[string]any{reference: nil})
}
func (s *KubernetesSecretStore) patch(ctx context.Context, data map[string]any) error {
payload, err := json.Marshal(map[string]any{"data": data})
if err != nil {
return err
}
request, err := s.request(ctx, http.MethodPatch, bytes.NewReader(payload))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/merge-patch+json")
response, err := s.client.Do(request)
if err != nil {
return fmt.Errorf("update Kubernetes security event Secret: %w", err)
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
if response.StatusCode == http.StatusNotFound {
return errors.New("Kubernetes security event Secret is not provisioned")
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("Kubernetes security event Secret returned HTTP %d", response.StatusCode)
}
return nil
}
func (s *KubernetesSecretStore) request(ctx context.Context, method string, body io.Reader) (*http.Request, error) {
token, err := os.ReadFile(s.tokenFile)
if err != nil || strings.TrimSpace(string(token)) == "" {
clear(token)
return nil, errors.New("Kubernetes service account token is unavailable")
}
request, err := http.NewRequestWithContext(ctx, method, s.endpoint, body)
if err != nil {
clear(token)
return nil, err
}
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(token)))
request.Header.Set("Accept", "application/json")
clear(token)
return request, nil
}
func validKubernetesDNSName(value string) bool {
return len(value) > 0 && len(value) <= 253 && kubernetesDNSNamePattern.MatchString(value)
}

View File

@ -0,0 +1,95 @@
package securityevents
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
)
func TestKubernetesSecretStoreUsesOnePreprovisionedSecret(t *testing.T) {
var mutex sync.Mutex
data := map[string]string{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/namespaces/easyai/secrets/easyai-gateway-security-events" || r.Header.Get("Authorization") != "Bearer service-account-token" {
t.Fatalf("unexpected Kubernetes request: %s authorization=%t", r.URL.Path, r.Header.Get("Authorization") != "")
}
mutex.Lock()
defer mutex.Unlock()
switch r.Method {
case http.MethodGet:
_ = json.NewEncoder(w).Encode(map[string]any{"data": data})
case http.MethodPatch:
var patch struct {
Data map[string]*string `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
t.Fatal(err)
}
for key, value := range patch.Data {
if value == nil {
delete(data, key)
} else {
data[key] = *value
}
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": data})
default:
http.Error(w, "unsupported", http.StatusMethodNotAllowed)
}
}))
defer server.Close()
tokenFile := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(tokenFile, []byte("service-account-token\n"), 0o600); err != nil {
t.Fatal(err)
}
secrets, err := NewKubernetesSecretStore(KubernetesSecretStoreConfig{
Namespace: "easyai", SecretName: "easyai-gateway-security-events", APIServer: server.URL,
TokenFile: tokenFile, HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
value := []byte("0123456789abcdef0123456789abcdef")
if err := secrets.Put(context.Background(), "ssf-push-connection", value); err != nil {
t.Fatal(err)
}
if data["ssf-push-connection"] != base64.StdEncoding.EncodeToString(value) {
t.Fatal("Push Bearer was not stored in Kubernetes Secret data")
}
loaded, err := secrets.Get(context.Background(), "ssf-push-connection")
if err != nil || string(loaded) != string(value) {
t.Fatalf("loaded secret mismatch: err=%v", err)
}
clear(loaded)
if err := secrets.Delete(context.Background(), "ssf-push-connection"); err != nil {
t.Fatal(err)
}
if _, ok := data["ssf-push-connection"]; ok {
t.Fatal("Push Bearer key was not removed")
}
}
func TestKubernetesSecretStoreRejectsUnprovisionedSecret(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.NotFound(w, nil) }))
defer server.Close()
tokenFile := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(tokenFile, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
secrets, err := NewKubernetesSecretStore(KubernetesSecretStoreConfig{
Namespace: "easyai", SecretName: "easyai-gateway-security-events", APIServer: server.URL,
TokenFile: tokenFile, HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
if err := secrets.Put(context.Background(), "ssf-push-connection", []byte("0123456789abcdef0123456789abcdef")); err == nil {
t.Fatal("missing pre-provisioned Kubernetes Secret was accepted")
}
}

View File

@ -0,0 +1,195 @@
package securityevents
import (
"context"
"errors"
"fmt"
"net/http"
"sync/atomic"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type MetricsSnapshotProvider interface {
SecurityEventMetrics(context.Context, string, string) (string, *time.Time, error)
}
type DynamicMetricsSnapshotProvider interface {
MetricsSnapshotProvider
SecurityEventConnection(context.Context) (store.SecurityEventConnection, error)
}
type Metrics struct {
accepted atomic.Uint64
rejected atomic.Uint64
duplicate atomic.Uint64
sessionsDeleted atomic.Uint64
watermarkRejected atomic.Uint64
verificationAccepted atomic.Uint64
heartbeatAccepted atomic.Uint64
heartbeatFailed atomic.Uint64
introspectionActive atomic.Uint64
introspectionInactive atomic.Uint64
introspectionFailed atomic.Uint64
jwksSSFFailed atomic.Uint64
jwksOIDCFailed atomic.Uint64
processingCount atomic.Uint64
processingNanos atomic.Uint64
processingBuckets [6]atomic.Uint64
}
var processingDurationBounds = [...]time.Duration{
10 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond,
500 * time.Millisecond, time.Second, 3 * time.Second,
}
func (m *Metrics) ObserveReceipt(outcome string, duration time.Duration, sessionsDeleted int64) {
switch outcome {
case "accepted":
m.accepted.Add(1)
case "duplicate":
m.duplicate.Add(1)
default:
m.rejected.Add(1)
}
if sessionsDeleted > 0 {
m.sessionsDeleted.Add(uint64(sessionsDeleted))
}
if duration < 0 {
duration = 0
}
m.processingCount.Add(1)
m.processingNanos.Add(uint64(duration))
for index, bound := range processingDurationBounds {
if duration <= bound {
m.processingBuckets[index].Add(1)
}
}
}
func (m *Metrics) ObserveWatermarkRejection() { m.watermarkRejected.Add(1) }
func (m *Metrics) ObserveVerificationAccepted() { m.verificationAccepted.Add(1) }
func (m *Metrics) ObserveHeartbeat(outcome string) {
if outcome == "accepted" {
m.heartbeatAccepted.Add(1)
return
}
m.heartbeatFailed.Add(1)
}
func (m *Metrics) ObserveIntrospection(outcome string) {
switch outcome {
case "active":
m.introspectionActive.Add(1)
case "inactive":
m.introspectionInactive.Add(1)
default:
m.introspectionFailed.Add(1)
}
}
func (m *Metrics) ObserveJWKSRefreshFailure(source string) {
if source == "ssf" {
m.jwksSSFFailed.Add(1)
return
}
m.jwksOIDCFailed.Add(1)
}
func (m *Metrics) Handler(provider MetricsSnapshotProvider, issuer, audience string, enabled bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mode := "disabled"
var lastVerificationAt *time.Time
if enabled {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
var err error
mode, lastVerificationAt, err = provider.SecurityEventMetrics(ctx, issuer, audience)
if err != nil {
http.Error(w, "metrics unavailable", http.StatusServiceUnavailable)
return
}
}
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
outcomeCounters(w, "easyai_gateway_ssf_receipts_total", "Received SETs by bounded outcome.", []outcomeValue{
{"accepted", m.accepted.Load()}, {"rejected", m.rejected.Load()}, {"duplicate", m.duplicate.Load()},
})
plainCounter(w, "easyai_gateway_ssf_sessions_deleted_total", "OIDC browser sessions deleted by accepted session-revoked events.", m.sessionsDeleted.Load())
plainCounter(w, "easyai_gateway_ssf_watermark_rejections_total", "OIDC access tokens rejected by a local revocation watermark.", m.watermarkRejected.Load())
plainCounter(w, "easyai_gateway_ssf_verifications_total", "Valid verification SETs accepted by the receiver.", m.verificationAccepted.Load())
outcomeCounters(w, "easyai_gateway_ssf_heartbeat_requests_total", "Verification requests by bounded outcome.", []outcomeValue{
{"accepted", m.heartbeatAccepted.Load()}, {"failed", m.heartbeatFailed.Load()},
})
outcomeCounters(w, "easyai_gateway_oidc_introspection_total", "OIDC introspection requests by bounded outcome.", []outcomeValue{
{"active", m.introspectionActive.Load()}, {"inactive", m.introspectionInactive.Load()}, {"failed", m.introspectionFailed.Load()},
})
outcomeCounters(w, "easyai_gateway_jwks_refresh_failures_total", "JWKS refresh failures by bounded source.", []outcomeValue{
{"ssf", m.jwksSSFFailed.Load()}, {"oidc", m.jwksOIDCFailed.Load()},
})
fmt.Fprintln(w, "# HELP easyai_gateway_ssf_processing_duration_seconds SSF receiver transaction processing time.")
fmt.Fprintln(w, "# TYPE easyai_gateway_ssf_processing_duration_seconds histogram")
for index, bound := range processingDurationBounds {
fmt.Fprintf(w, "easyai_gateway_ssf_processing_duration_seconds_bucket{le=\"%.2f\"} %d\n", bound.Seconds(), m.processingBuckets[index].Load())
}
fmt.Fprintf(w, "easyai_gateway_ssf_processing_duration_seconds_bucket{le=\"+Inf\"} %d\n", m.processingCount.Load())
fmt.Fprintf(w, "easyai_gateway_ssf_processing_duration_seconds_sum %.6f\n", float64(m.processingNanos.Load())/float64(time.Second))
fmt.Fprintf(w, "easyai_gateway_ssf_processing_duration_seconds_count %d\n", m.processingCount.Load())
verificationAge := float64(0)
if lastVerificationAt != nil {
verificationAge = time.Since(*lastVerificationAt).Seconds()
if verificationAge < 0 {
verificationAge = 0
}
}
fmt.Fprintln(w, "# HELP easyai_gateway_ssf_verification_age_seconds Seconds since the last matched verification SET.")
fmt.Fprintln(w, "# TYPE easyai_gateway_ssf_verification_age_seconds gauge")
fmt.Fprintf(w, "easyai_gateway_ssf_verification_age_seconds %.6f\n", verificationAge)
fmt.Fprintln(w, "# HELP easyai_gateway_ssf_mode Current SSF receiver mode as a one-hot gauge.")
fmt.Fprintln(w, "# TYPE easyai_gateway_ssf_mode gauge")
for _, modeName := range []string{"disabled", "bootstrap", "push_healthy", "introspection_fallback"} {
value := 0
if mode == modeName {
value = 1
}
fmt.Fprintf(w, "easyai_gateway_ssf_mode{mode=\"%s\"} %d\n", modeName, value)
}
})
}
func (m *Metrics) DynamicHandler(provider DynamicMetricsSnapshotProvider) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
connection, err := provider.SecurityEventConnection(r.Context())
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
m.Handler(provider, "", "", false).ServeHTTP(w, r)
return
}
if err != nil {
http.Error(w, "metrics unavailable", http.StatusServiceUnavailable)
return
}
if connection.Audience == nil {
m.Handler(provider, "", "", false).ServeHTTP(w, r)
return
}
m.Handler(provider, connection.TransmitterIssuer, *connection.Audience, true).ServeHTTP(w, r)
})
}
type outcomeValue struct {
name string
value uint64
}
func outcomeCounters(w http.ResponseWriter, name, help string, values []outcomeValue) {
fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s counter\n", name, help, name)
for _, value := range values {
fmt.Fprintf(w, "%s{outcome=\"%s\"} %d\n", name, value.name, value.value)
}
}
func plainCounter(w http.ResponseWriter, name, help string, value uint64) {
fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s counter\n%s %d\n", name, help, name, name, value)
}

View File

@ -0,0 +1,50 @@
package securityevents
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type metricsSnapshot struct {
mode string
last time.Time
}
func (m metricsSnapshot) SecurityEventMetrics(context.Context, string, string) (string, *time.Time, error) {
return m.mode, &m.last, nil
}
func TestMetricsExposeBoundedOutcomesAndState(t *testing.T) {
metrics := &Metrics{}
metrics.ObserveReceipt("accepted", 20*time.Millisecond, 2)
metrics.ObserveReceipt("duplicate", 10*time.Millisecond, 0)
metrics.ObserveWatermarkRejection()
metrics.ObserveHeartbeat("accepted")
metrics.ObserveIntrospection("failed")
metrics.ObserveJWKSRefreshFailure("ssf")
recorder := httptest.NewRecorder()
metrics.Handler(metricsSnapshot{mode: "push_healthy", last: time.Now().Add(-time.Minute)}, "issuer", "audience", true).
ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("metrics status=%d", recorder.Code)
}
for _, expected := range []string{
`easyai_gateway_ssf_receipts_total{outcome="accepted"} 1`,
`easyai_gateway_ssf_receipts_total{outcome="duplicate"} 1`,
`easyai_gateway_ssf_sessions_deleted_total 2`,
`easyai_gateway_ssf_watermark_rejections_total 1`,
`easyai_gateway_ssf_processing_duration_seconds_bucket{le="0.05"} 2`,
`easyai_gateway_ssf_mode{mode="push_healthy"} 1`,
`easyai_gateway_oidc_introspection_total{outcome="failed"} 1`,
`easyai_gateway_jwks_refresh_failures_total{outcome="ssf"} 1`,
} {
if !strings.Contains(recorder.Body.String(), expected) {
t.Fatalf("missing metric %q in:\n%s", expected, recorder.Body.String())
}
}
}

View File

@ -0,0 +1,79 @@
package securityevents
import (
"context"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
const (
defaultSecurityEventRetirementDuration = 6 * time.Minute
securityEventRetirementWorkerInterval = time.Minute
)
type SecurityEventRetirementRepository interface {
SecurityEventConnection(context.Context) (store.SecurityEventConnection, error)
FinalizeRetiringSecurityEventConnection(context.Context, string, int64) error
}
// RunSecurityEventRetirementWorker owns the local end of SSF retirement at the
// server lifecycle. It intentionally does not share an Active identity
// Runtime's context, because that Runtime is closed before the RFC 8935 overlap
// period expires. Store-level generation CAS makes one worker per replica safe.
func RunSecurityEventRetirementWorker(ctx context.Context, repository SecurityEventRetirementRepository) {
runSecurityEventRetirementWorker(
ctx,
repository,
defaultSecurityEventRetirementDuration,
securityEventRetirementWorkerInterval,
)
}
func runSecurityEventRetirementWorker(
ctx context.Context,
repository SecurityEventRetirementRepository,
retirementDuration time.Duration,
interval time.Duration,
) {
if repository == nil {
return
}
if retirementDuration <= 0 {
retirementDuration = defaultSecurityEventRetirementDuration
}
if interval <= 0 {
interval = securityEventRetirementWorkerInterval
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
finalizeDueSecurityEventRetirement(ctx, repository, retirementDuration)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func finalizeDueSecurityEventRetirement(
ctx context.Context,
repository SecurityEventRetirementRepository,
retirementDuration time.Duration,
) {
connection, err := repository.SecurityEventConnection(ctx)
if err != nil || connection.LifecycleStatus != "retiring" {
return
}
if retirementDuration <= 0 {
retirementDuration = defaultSecurityEventRetirementDuration
}
if time.Since(connection.UpdatedAt) < retirementDuration {
return
}
// Finalization atomically queues every Secret reference and removes only the
// exact retiring generation. A concurrent worker or changed generation is a
// benign CAS miss and will be observed on the next loop.
_ = repository.FinalizeRetiringSecurityEventConnection(ctx, connection.ConnectionID, connection.Version)
}

View File

@ -0,0 +1,178 @@
package securityevents
import (
"context"
"errors"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
"github.com/google/uuid"
)
func TestSecurityEventRetirementWorkerOutlivesCanceledRuntime(t *testing.T) {
connection := retiringWorkerTestConnection(time.Now().UTC())
repository := &memoryConnectionRepository{connection: &connection}
secrets := &memorySecretStore{values: retirementWorkerTestSecrets(connection)}
runtimeCtx, cancelRuntime := context.WithCancel(context.Background())
manager := &ConnectionManager{
ctx: runtimeCtx, repository: repository, secrets: secrets,
config: ConnectionManagerConfig{RetirementDuration: 40 * time.Millisecond},
}
go manager.finishRetirement(connection)
cancelRuntime()
time.Sleep(10 * time.Millisecond)
if _, err := repository.SecurityEventConnection(context.Background()); err != nil {
t.Fatalf("runtime cancellation unexpectedly finalized retirement: %v", err)
}
serverCtx, cancelServer := context.WithCancel(context.Background())
defer cancelServer()
go runSecurityEventRetirementWorker(serverCtx, repository, 40*time.Millisecond, 5*time.Millisecond)
waitForRetirementFinalization(t, repository)
for reference := range retirementWorkerTestSecrets(connection) {
repository.mutex.Lock()
_, queued := repository.cleanupDue[reference]
repository.mutex.Unlock()
if !queued {
t.Fatalf("retired Secret %q was not durably queued", reference)
}
if !secrets.Has(reference) {
t.Fatalf("retirement worker directly deleted Secret %q before the durable cleanup worker claimed it", reference)
}
}
}
func TestSecurityEventRetirementWorkerResumesAfterServerRestart(t *testing.T) {
connection := retiringWorkerTestConnection(time.Now().UTC())
repository := &memoryConnectionRepository{connection: &connection}
firstCtx, stopFirst := context.WithCancel(context.Background())
go runSecurityEventRetirementWorker(firstCtx, repository, 80*time.Millisecond, 5*time.Millisecond)
time.Sleep(15 * time.Millisecond)
stopFirst()
if _, err := repository.SecurityEventConnection(context.Background()); err != nil {
t.Fatalf("not-yet-due retirement disappeared before restart: %v", err)
}
time.Sleep(75 * time.Millisecond)
secondCtx, stopSecond := context.WithCancel(context.Background())
defer stopSecond()
go runSecurityEventRetirementWorker(secondCtx, repository, 80*time.Millisecond, 5*time.Millisecond)
waitForRetirementFinalization(t, repository)
}
func TestConcurrentSecurityEventRetirementWorkersFinalizeOneGenerationOnce(t *testing.T) {
connection := retiringWorkerTestConnection(time.Now().UTC().Add(-time.Minute))
repository := &memoryConnectionRepository{connection: &connection}
start := make(chan struct{})
done := make(chan struct{}, 2)
for range 2 {
go func() {
<-start
finalizeDueSecurityEventRetirement(context.Background(), repository, time.Millisecond)
done <- struct{}{}
}()
}
close(start)
<-done
<-done
if _, err := repository.SecurityEventConnection(context.Background()); !errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
t.Fatalf("current retiring generation still exists: %v", err)
}
repository.mutex.Lock()
finalizeCalls := repository.finalizeCalls
queued := len(repository.cleanupDue)
repository.mutex.Unlock()
if finalizeCalls != 1 {
t.Fatalf("retirement generation finalized %d times, want 1", finalizeCalls)
}
if queued != len(retirementWorkerTestSecrets(connection)) {
t.Fatalf("queued Secret references=%d want=%d", queued, len(retirementWorkerTestSecrets(connection)))
}
}
func TestSecurityEventRetirementWorkerPreservesChangedGeneration(t *testing.T) {
connection := retiringWorkerTestConnection(time.Now().UTC().Add(-time.Minute))
initialVersion := connection.Version
repository := &memoryConnectionRepository{connection: &connection}
repository.beforeFinalize = func(current *store.SecurityEventConnection) {
current.LifecycleStatus = "enabled"
current.Version++
}
finalizeDueSecurityEventRetirement(context.Background(), repository, time.Millisecond)
current, err := repository.SecurityEventConnection(context.Background())
if err != nil || current.LifecycleStatus != "enabled" || current.Version != initialVersion+1 {
t.Fatalf("changed generation was not preserved: lifecycle=%q version=%d err=%v", current.LifecycleStatus, current.Version, err)
}
repository.mutex.Lock()
queued := len(repository.cleanupDue)
repository.mutex.Unlock()
if queued != 0 {
t.Fatalf("changed generation queued %d active Secret references", queued)
}
}
func TestSecurityEventRetirementWorkerKeepsConnectionWhenCleanupQueueCannotAdoptReferences(t *testing.T) {
connection := retiringWorkerTestConnection(time.Now().UTC().Add(-time.Minute))
repository := &memoryConnectionRepository{
connection: &connection,
cleanupClaims: map[string]string{connection.CredentialRef: uuid.NewString()},
}
finalizeDueSecurityEventRetirement(context.Background(), repository, time.Millisecond)
current, err := repository.SecurityEventConnection(context.Background())
if err != nil || current.ConnectionID != connection.ConnectionID || current.LifecycleStatus != "retiring" {
t.Fatalf("failed durable handoff lost retiring connection: lifecycle=%q err=%v", current.LifecycleStatus, err)
}
repository.mutex.Lock()
finalizeCalls := repository.finalizeCalls
repository.mutex.Unlock()
if finalizeCalls != 0 {
t.Fatalf("failed durable handoff finalized generation %d times", finalizeCalls)
}
}
func retiringWorkerTestConnection(updatedAt time.Time) store.SecurityEventConnection {
managementReference := "ssf-management-retirement-worker-" + uuid.NewString()
nextReference := "ssf-push-next-retirement-worker-" + uuid.NewString()
return store.SecurityEventConnection{
ConnectionID: uuid.NewString(),
CredentialRef: "ssf-push-retirement-worker-" + uuid.NewString(),
NextCredentialRef: &nextReference,
ManagementCredentialRef: &managementReference,
LifecycleStatus: "retiring",
Version: 9,
UpdatedAt: updatedAt,
}
}
func retirementWorkerTestSecrets(connection store.SecurityEventConnection) map[string][]byte {
secrets := map[string][]byte{connection.CredentialRef: []byte("retired-push-secret-value")}
if connection.NextCredentialRef != nil {
secrets[*connection.NextCredentialRef] = []byte("retired-next-push-secret-value")
}
if connection.ManagementCredentialRef != nil {
secrets[*connection.ManagementCredentialRef] = []byte("retired-management-secret-value")
}
return secrets
}
func waitForRetirementFinalization(t *testing.T, repository *memoryConnectionRepository) {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if _, err := repository.SecurityEventConnection(context.Background()); errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("retiring connection was not finalized by the server-lifecycle worker")
}

View File

@ -0,0 +1,112 @@
package securityevents
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
)
var secretReferencePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,127}$`)
var ErrSecretNotFound = errors.New("security event secret not found")
type SecretStore interface {
Put(context.Context, string, []byte) error
Get(context.Context, string) ([]byte, error)
Delete(context.Context, string) error
}
type FileSecretStore struct{ directory string }
func NewFileSecretStore(directory string) (*FileSecretStore, error) {
if directory == "" {
return nil, errors.New("security event secret directory is required")
}
if err := os.MkdirAll(directory, 0o700); err != nil {
return nil, fmt.Errorf("create security event secret directory: %w", err)
}
if err := os.Chmod(directory, 0o700); err != nil {
return nil, fmt.Errorf("protect security event secret directory: %w", err)
}
return &FileSecretStore{directory: directory}, nil
}
func (s *FileSecretStore) Put(_ context.Context, reference string, value []byte) error {
path, err := s.path(reference)
if err != nil {
return err
}
if len(value) < 32 || len(value) > 4096 {
return errors.New("security event secret length is invalid")
}
temporary, err := os.CreateTemp(s.directory, ".ssf-secret-*")
if err != nil {
return fmt.Errorf("create temporary security event secret: %w", err)
}
temporaryName := temporary.Name()
defer func() { _ = os.Remove(temporaryName) }()
if err := temporary.Chmod(0o600); err != nil {
_ = temporary.Close()
return err
}
if _, err := temporary.Write(value); err != nil {
_ = temporary.Close()
return fmt.Errorf("write security event secret: %w", err)
}
if err := temporary.Sync(); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
if err := os.Rename(temporaryName, path); err != nil {
return fmt.Errorf("publish security event secret: %w", err)
}
return os.Chmod(path, 0o600)
}
func (s *FileSecretStore) Get(_ context.Context, reference string) ([]byte, error) {
path, err := s.path(reference)
if err != nil {
return nil, err
}
info, err := os.Stat(path)
if errors.Is(err, os.ErrNotExist) {
return nil, ErrSecretNotFound
}
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0o077 != 0 {
return nil, errors.New("security event secret file is not protected")
}
value, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read security event secret: %w", err)
}
if len(value) < 32 || len(value) > 4096 {
clear(value)
return nil, errors.New("security event secret length is invalid")
}
return value, nil
}
func (s *FileSecretStore) Delete(_ context.Context, reference string) error {
path, err := s.path(reference)
if err != nil {
return err
}
err = os.Remove(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
func (s *FileSecretStore) path(reference string) (string, error) {
if !secretReferencePattern.MatchString(reference) {
return "", errors.New("security event secret reference is invalid")
}
return filepath.Join(s.directory, reference), nil
}

View File

@ -0,0 +1,40 @@
package securityevents
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"testing"
)
func TestFileSecretStoreCreatesProtectedFilesAndNeverAcceptsTraversal(t *testing.T) {
directory := filepath.Join(t.TempDir(), "ssf")
secrets, err := NewFileSecretStore(directory)
if err != nil {
t.Fatal(err)
}
value := bytes.Repeat([]byte{0x41}, 32)
if err := secrets.Put(context.Background(), "ssf-push-connection", value); err != nil {
t.Fatal(err)
}
info, err := os.Stat(filepath.Join(directory, "ssf-push-connection"))
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("secret mode=%v err=%v", info.Mode().Perm(), err)
}
loaded, err := secrets.Get(context.Background(), "ssf-push-connection")
if err != nil || !bytes.Equal(loaded, value) {
t.Fatalf("secret mismatch err=%v", err)
}
clear(loaded)
if err := secrets.Put(context.Background(), "../escape", value); err == nil {
t.Fatal("path traversal reference was accepted")
}
if err := secrets.Delete(context.Background(), "ssf-push-connection"); err != nil {
t.Fatal(err)
}
if _, err := secrets.Get(context.Background(), "ssf-push-connection"); !errors.Is(err, ErrSecretNotFound) {
t.Fatalf("deleted secret error=%v", err)
}
}

View File

@ -0,0 +1,222 @@
package securityevents
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type StateRepository interface {
EnsureSecurityEventStreamState(context.Context, string, string, string) error
BeginSecurityEventVerification(context.Context, string, string, []byte, time.Time) error
RecordSecurityEventHeartbeatFailure(context.Context, string, string, string) error
AdvanceSecurityEventStreamState(context.Context, string, string, time.Time, time.Duration) error
EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error)
}
type ServiceConfig struct {
Issuer string
Audience string
StreamID string
ManagementTokenURL string
ManagementClientID string
ManagementSecret string
HeartbeatInterval time.Duration
StaleAfter time.Duration
HTTPClient *http.Client
}
type Service struct {
repository StateRepository
config ServiceConfig
client *http.Client
clock func() time.Time
tokenMu sync.Mutex
token string
tokenUntil time.Time
metrics *Metrics
}
func (s *Service) SetMetrics(metrics *Metrics) { s.metrics = metrics }
func NewService(repository StateRepository, config ServiceConfig) (*Service, error) {
if repository == nil || config.Issuer == "" || config.Audience == "" || config.StreamID == "" ||
config.ManagementTokenURL == "" || config.ManagementClientID == "" || config.ManagementSecret == "" {
return nil, errors.New("SSF heartbeat configuration is incomplete")
}
if config.HeartbeatInterval <= 0 || config.StaleAfter < 2*config.HeartbeatInterval {
return nil, errors.New("SSF heartbeat timing is invalid")
}
client := config.HTTPClient
if client == nil {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
transport.DisableCompression = true
client = &http.Client{Timeout: 10 * time.Second,
Transport: transport,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
}
return &Service{repository: repository, config: config, client: client, clock: time.Now}, nil
}
func (s *Service) Start(ctx context.Context) error {
if err := s.Initialize(ctx); err != nil {
return err
}
go s.Run(ctx)
return nil
}
func (s *Service) Initialize(ctx context.Context) error {
return s.repository.EnsureSecurityEventStreamState(ctx, s.config.Issuer, s.config.Audience, s.config.StreamID)
}
func (s *Service) Run(ctx context.Context) { s.run(ctx) }
// RequestVerification asks for an immediate verification without changing the
// remote stream status. In particular, this never re-enables a stream paused
// by an authentication-center administrator.
func (s *Service) RequestVerification(ctx context.Context) { s.sendVerification(ctx) }
func (s *Service) Evaluate(ctx context.Context, identity auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error) {
result, err := s.repository.EvaluateOIDCSecurityEvent(
ctx, s.config.Issuer, s.config.Audience, identity.Issuer, identity.TenantID, identity.Subject,
identity.IssuedAt, s.clock().UTC(), s.config.StaleAfter,
)
if err != nil {
return auth.OIDCSecurityEventEvaluation{}, err
}
if result.Revoked && s.metrics != nil {
s.metrics.ObserveWatermarkRejection()
}
return auth.OIDCSecurityEventEvaluation{Revoked: result.Revoked, RequireIntrospection: result.RequireIntrospection}, nil
}
func (s *Service) run(ctx context.Context) {
s.sendVerification(ctx)
ticker := time.NewTicker(s.config.HeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.sendVerification(ctx)
}
}
}
func (s *Service) sendVerification(ctx context.Context) {
outcome := "failed"
defer func() {
if s.metrics != nil {
s.metrics.ObserveHeartbeat(outcome)
}
}()
if err := s.repository.AdvanceSecurityEventStreamState(ctx, s.config.Issuer, s.config.Audience, s.clock().UTC(), s.config.StaleAfter); err != nil {
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "state_advance_failed")
}
state, err := randomVerificationState()
if err != nil {
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "state_generation_failed")
return
}
hash := sha256.Sum256([]byte(state))
now := s.clock().UTC()
if err := s.repository.BeginSecurityEventVerification(ctx, s.config.Issuer, s.config.Audience, hash[:], now); err != nil {
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "state_persistence_failed")
return
}
token, err := s.managementToken(ctx)
if err != nil {
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "management_token_failed")
return
}
payload, _ := json.Marshal(map[string]string{"stream_id": s.config.StreamID, "state": state})
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.config.Issuer, "/")+"/v1/verify", bytes.NewReader(payload))
if err != nil {
return
}
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Content-Type", "application/json")
response, err := s.client.Do(request)
if err != nil {
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "verification_request_failed")
return
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
if response.StatusCode != http.StatusNoContent {
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, fmt.Sprintf("verification_http_%d", response.StatusCode))
return
}
outcome = "accepted"
}
func (s *Service) managementToken(ctx context.Context) (string, error) {
s.tokenMu.Lock()
defer s.tokenMu.Unlock()
if s.token != "" && s.clock().Before(s.tokenUntil) {
return s.token, nil
}
form := url.Values{"grant_type": {"client_credentials"}, "scope": {"ssf.stream.read ssf.stream.verify"}}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, s.config.ManagementTokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
request.SetBasicAuth(s.config.ManagementClientID, s.config.ManagementSecret)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response, err := s.client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
return "", fmt.Errorf("management token endpoint returned HTTP %d", response.StatusCode)
}
payload, err := io.ReadAll(io.LimitReader(response.Body, 64*1024+1))
if err != nil || len(payload) > 64*1024 {
return "", errors.New("management token response is too large")
}
var result struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
}
if json.Unmarshal(payload, &result) != nil || result.AccessToken == "" || !strings.EqualFold(result.TokenType, "Bearer") {
return "", errors.New("management token response is invalid")
}
if result.ExpiresIn <= 0 {
result.ExpiresIn = 60
}
cacheFor := time.Duration(result.ExpiresIn) * time.Second
if cacheFor > 30*time.Second {
cacheFor -= 30 * time.Second
}
s.token, s.tokenUntil = result.AccessToken, s.clock().Add(cacheFor)
return s.token, nil
}
func randomVerificationState() (string, error) {
payload := make([]byte, 32)
if _, err := rand.Read(payload); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(payload), nil
}

View File

@ -0,0 +1,86 @@
package securityevents
import (
"context"
"crypto/sha256"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type fakeStateRepository struct {
mutex sync.Mutex
stateHash []byte
verification chan struct{}
evaluation store.SecurityEventEvaluation
}
func (*fakeStateRepository) EnsureSecurityEventStreamState(context.Context, string, string, string) error {
return nil
}
func (f *fakeStateRepository) BeginSecurityEventVerification(_ context.Context, _, _ string, hash []byte, _ time.Time) error {
f.mutex.Lock()
f.stateHash = append([]byte(nil), hash...)
f.mutex.Unlock()
return nil
}
func (*fakeStateRepository) RecordSecurityEventHeartbeatFailure(context.Context, string, string, string) error {
return nil
}
func (*fakeStateRepository) AdvanceSecurityEventStreamState(context.Context, string, string, time.Time, time.Duration) error {
return nil
}
func (f *fakeStateRepository) EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error) {
return f.evaluation, nil
}
func TestServiceRequestsVerificationWithMachineTokenAndPersistsStateFirst(t *testing.T) {
repository := &fakeStateRepository{verification: make(chan struct{}, 1)}
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
clientID, secret, ok := r.BasicAuth()
if !ok || clientID != "gateway-ssf" || secret != "management-secret" {
t.Fatal("machine client authentication is missing")
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"management-token","token_type":"Bearer","expires_in":300}`))
case "/ssf/v1/verify":
if r.Header.Get("Authorization") != "Bearer management-token" {
t.Fatal("verification bearer is missing")
}
repository.mutex.Lock()
persisted := len(repository.stateHash) == sha256.Size
repository.mutex.Unlock()
if !persisted {
t.Fatal("verification state was not persisted before request")
}
w.WriteHeader(http.StatusNoContent)
repository.verification <- struct{}{}
}
}))
defer server.Close()
service, err := NewService(repository, ServiceConfig{
Issuer: server.URL + "/ssf", Audience: "urn:easyai:ssf:receiver:app", StreamID: "00000000-0000-4000-8000-000000000001",
ManagementTokenURL: server.URL + "/token", ManagementClientID: "gateway-ssf", ManagementSecret: "management-secret",
HeartbeatInterval: time.Hour, StaleAfter: 2 * time.Hour, HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := service.Start(ctx); err != nil {
t.Fatal(err)
}
select {
case <-repository.verification:
case <-time.After(2 * time.Second):
t.Fatal("verification was not requested")
}
}

View File

@ -0,0 +1,412 @@
package securityevents
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
const (
SessionRevokedEventType = "https://schemas.openid.net/secevent/caep/event-type/session-revoked"
VerificationEventType = "https://schemas.openid.net/secevent/ssf/event-type/verification"
StreamUpdatedEventType = "https://schemas.openid.net/secevent/ssf/event-type/stream-updated"
)
type Event struct {
Issuer string
Audience string
JTI string
TransactionID string
IssuedAt time.Time
EventType string
EventTimestamp time.Time
SubjectIssuer string
Subject string
TenantID string
InitiatingEntity string
StreamID string
State string
StreamStatus string
Reason string
}
type VerifierConfig struct {
TransmitterIssuer string
Audience string
SubjectIssuer string
TenantID string
StreamID string
ClockSkew time.Duration
HTTPClient *http.Client
}
type Verifier struct {
config VerifierConfig
client *http.Client
mu sync.Mutex
keys map[string]*ecdsa.PublicKey
expiresAt time.Time
metrics *Metrics
clock func() time.Time
}
func (v *Verifier) SetMetrics(metrics *Metrics) { v.metrics = metrics }
type transmitterMetadata struct {
Issuer string `json:"issuer"`
JWKSURI string `json:"jwks_uri"`
}
type jwkSet struct {
Keys []struct {
KID string `json:"kid"`
KTY string `json:"kty"`
Use string `json:"use"`
Alg string `json:"alg"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
} `json:"keys"`
}
type protocolError struct {
Code string
}
func (e *protocolError) Error() string { return e.Code }
func NewVerifier(config VerifierConfig) (*Verifier, error) {
config.TransmitterIssuer = strings.TrimRight(strings.TrimSpace(config.TransmitterIssuer), "/")
config.SubjectIssuer = strings.TrimRight(strings.TrimSpace(config.SubjectIssuer), "/")
if !absoluteHTTPURL(config.TransmitterIssuer) || !absoluteHTTPURL(config.SubjectIssuer) || config.Audience == "" || config.TenantID == "" {
return nil, errors.New("SSF transmitter, audience, subject issuer, and tenant are required")
}
if _, err := uuid.Parse(config.StreamID); err != nil {
return nil, errors.New("SSF stream id must be a UUID")
}
if config.ClockSkew < 0 || config.ClockSkew > 5*time.Minute {
return nil, errors.New("SSF clock skew is invalid")
}
client := config.HTTPClient
if client == nil {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
transport.DisableCompression = true
client = &http.Client{Timeout: 10 * time.Second,
Transport: transport,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
}
return &Verifier{config: config, client: client, keys: make(map[string]*ecdsa.PublicKey), clock: time.Now}, nil
}
func (v *Verifier) Verify(ctx context.Context, raw string) (Event, error) {
parser := jwt.NewParser(jwt.WithValidMethods([]string{"ES256"}))
unverified, _, err := parser.ParseUnverified(raw, jwt.MapClaims{})
if err != nil || unverified == nil || unverified.Header["typ"] != "secevent+jwt" || unverified.Header["alg"] != "ES256" {
return Event{}, &protocolError{Code: "invalid_request"}
}
unverifiedClaims, ok := unverified.Claims.(jwt.MapClaims)
if !ok {
return Event{}, &protocolError{Code: "invalid_request"}
}
if stringClaim(unverifiedClaims, "iss") != v.config.TransmitterIssuer {
return Event{}, &protocolError{Code: "invalid_issuer"}
}
if !claimHasAudience(unverifiedClaims["aud"], v.config.Audience) {
return Event{}, &protocolError{Code: "invalid_audience"}
}
kid, _ := unverified.Header["kid"].(string)
if kid == "" {
return Event{}, &protocolError{Code: "invalid_key"}
}
key, err := v.key(ctx, kid)
if err != nil {
return Event{}, &protocolError{Code: "invalid_key"}
}
token, err := jwt.Parse(raw, func(token *jwt.Token) (any, error) { return key, nil },
jwt.WithValidMethods([]string{"ES256"}), jwt.WithIssuer(v.config.TransmitterIssuer),
jwt.WithAudience(v.config.Audience), jwt.WithIssuedAt(), jwt.WithLeeway(v.config.ClockSkew))
if err != nil || !token.Valid {
if errors.Is(err, jwt.ErrTokenSignatureInvalid) {
return Event{}, &protocolError{Code: "invalid_key"}
}
return Event{}, &protocolError{Code: "invalid_request"}
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return Event{}, &protocolError{Code: "invalid_request"}
}
if _, present := claims["sub"]; present {
return Event{}, &protocolError{Code: "invalid_request"}
}
if _, present := claims["exp"]; present {
return Event{}, &protocolError{Code: "invalid_request"}
}
jti := stringClaim(claims, "jti")
if _, err := uuid.Parse(jti); err != nil {
return Event{}, &protocolError{Code: "invalid_request"}
}
issuedAt, err := claims.GetIssuedAt()
if err != nil || issuedAt == nil {
return Event{}, &protocolError{Code: "invalid_request"}
}
events, ok := claims["events"].(map[string]any)
if !ok || len(events) != 1 {
return Event{}, &protocolError{Code: "invalid_request"}
}
event := Event{Issuer: v.config.TransmitterIssuer, Audience: v.config.Audience, JTI: jti, TransactionID: stringClaim(claims, "txn"), IssuedAt: issuedAt.Time}
if payload, present := events[SessionRevokedEventType]; present {
value, ok := payload.(map[string]any)
if !ok || event.TransactionID == "" {
return Event{}, &protocolError{Code: "invalid_request"}
}
if _, err := uuid.Parse(event.TransactionID); err != nil {
return Event{}, &protocolError{Code: "invalid_request"}
}
event.EventType, event.EventTimestamp, event.InitiatingEntity = SessionRevokedEventType, unixClaim(value["event_timestamp"]), stringValue(value["initiating_entity"])
if event.EventTimestamp.IsZero() || event.EventTimestamp.After(v.now().Add(v.config.ClockSkew)) ||
!validInitiator(event.InitiatingEntity) || !v.readSessionSubject(claims["sub_id"], &event) {
return Event{}, &protocolError{Code: "invalid_request"}
}
return event, nil
}
if payload, present := events[VerificationEventType]; present {
value, ok := payload.(map[string]any)
if !ok || !v.readStreamSubject(claims["sub_id"], &event) {
return Event{}, &protocolError{Code: "invalid_request"}
}
event.EventType = VerificationEventType
if rawState, present := value["state"]; present {
var valid bool
event.State, valid = rawState.(string)
if !valid || len(event.State) > 1024 {
return Event{}, &protocolError{Code: "invalid_request"}
}
}
if len(event.State) > 1024 {
return Event{}, &protocolError{Code: "invalid_request"}
}
return event, nil
}
if payload, present := events[StreamUpdatedEventType]; present {
value, ok := payload.(map[string]any)
if !ok || !v.readStreamSubject(claims["sub_id"], &event) {
return Event{}, &protocolError{Code: "invalid_request"}
}
event.EventType = StreamUpdatedEventType
event.StreamStatus, event.Reason = stringValue(value["status"]), stringValue(value["reason"])
if event.StreamStatus != "enabled" && event.StreamStatus != "paused" && event.StreamStatus != "disabled" {
return Event{}, &protocolError{Code: "invalid_request"}
}
if len(event.Reason) > 1024 {
return Event{}, &protocolError{Code: "invalid_request"}
}
return event, nil
}
return Event{}, &protocolError{Code: "invalid_request"}
}
func claimHasAudience(raw any, expected string) bool {
switch value := raw.(type) {
case string:
return value == expected
case []string:
for _, audience := range value {
if audience == expected {
return true
}
}
case []any:
for _, item := range value {
if audience, ok := item.(string); ok && audience == expected {
return true
}
}
}
return false
}
func (v *Verifier) now() time.Time {
if v.clock != nil {
return v.clock().UTC()
}
return time.Now().UTC()
}
func (v *Verifier) readSessionSubject(raw any, event *Event) bool {
subject, ok := raw.(map[string]any)
if !ok || stringValue(subject["format"]) != "complex" {
return false
}
user, userOK := subject["user"].(map[string]any)
tenant, tenantOK := subject["tenant"].(map[string]any)
if !userOK || !tenantOK || stringValue(user["format"]) != "iss_sub" || stringValue(tenant["format"]) != "opaque" {
return false
}
event.SubjectIssuer, event.Subject, event.TenantID = strings.TrimRight(stringValue(user["iss"]), "/"), stringValue(user["sub"]), stringValue(tenant["id"])
if event.SubjectIssuer != v.config.SubjectIssuer || event.TenantID != v.config.TenantID {
return false
}
_, err := uuid.Parse(event.Subject)
return err == nil
}
func (v *Verifier) readStreamSubject(raw any, event *Event) bool {
subject, ok := raw.(map[string]any)
if !ok || stringValue(subject["format"]) != "opaque" || stringValue(subject["id"]) != v.config.StreamID {
return false
}
event.StreamID = v.config.StreamID
return true
}
func (v *Verifier) key(ctx context.Context, kid string) (*ecdsa.PublicKey, error) {
v.mu.Lock()
if key := v.keys[kid]; key != nil && time.Now().Before(v.expiresAt) {
v.mu.Unlock()
return key, nil
}
v.mu.Unlock()
if err := v.refresh(ctx); err != nil {
if v.metrics != nil {
v.metrics.ObserveJWKSRefreshFailure("ssf")
}
return nil, err
}
v.mu.Lock()
defer v.mu.Unlock()
key := v.keys[kid]
if key == nil {
return nil, errors.New("SSF signing key is unavailable")
}
return key, nil
}
func (v *Verifier) refresh(ctx context.Context) error {
v.mu.Lock()
defer v.mu.Unlock()
configurationURL, err := ssfConfigurationURL(v.config.TransmitterIssuer)
if err != nil {
return err
}
var metadata transmitterMetadata
if err := v.fetchJSON(ctx, configurationURL, &metadata); err != nil {
return err
}
if strings.TrimRight(metadata.Issuer, "/") != v.config.TransmitterIssuer || !sameOrigin(metadata.JWKSURI, v.config.TransmitterIssuer) {
return errors.New("SSF metadata is not bound to configured issuer")
}
var set jwkSet
if err := v.fetchJSON(ctx, metadata.JWKSURI, &set); err != nil {
return err
}
keys := make(map[string]*ecdsa.PublicKey)
for _, item := range set.Keys {
if item.KID == "" || item.KTY != "EC" || item.Alg != "ES256" || item.Crv != "P-256" || item.Use != "sig" {
continue
}
x, xErr := decodeCoordinate(item.X)
y, yErr := decodeCoordinate(item.Y)
if xErr == nil && yErr == nil && elliptic.P256().IsOnCurve(x, y) {
keys[item.KID] = &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}
}
}
if len(keys) == 0 {
return errors.New("SSF JWKS contains no ES256 keys")
}
v.keys, v.expiresAt = keys, time.Now().Add(5*time.Minute)
return nil
}
func (v *Verifier) fetchJSON(ctx context.Context, endpoint string, target any) error {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
request.Header.Set("Accept", "application/json")
response, err := v.client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("SSF endpoint returned HTTP %d", response.StatusCode)
}
payload, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1))
if err != nil || len(payload) > 1<<20 {
return errors.New("SSF endpoint response is too large")
}
if err := json.Unmarshal(payload, target); err != nil {
return errors.New("SSF endpoint returned invalid JSON")
}
return nil
}
func ssfConfigurationURL(issuer string) (string, error) {
parsed, err := url.Parse(issuer)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", errors.New("SSF issuer is invalid")
}
path := strings.TrimSuffix(parsed.EscapedPath(), "/")
parsed.Path, parsed.RawPath = "/.well-known/ssf-configuration"+path, ""
parsed.RawQuery, parsed.Fragment = "", ""
return parsed.String(), nil
}
func sameOrigin(candidate, issuer string) bool {
candidateURL, candidateErr := url.Parse(candidate)
issuerURL, issuerErr := url.Parse(issuer)
return candidateErr == nil && issuerErr == nil && candidateURL.Scheme == issuerURL.Scheme && strings.EqualFold(candidateURL.Host, issuerURL.Host) && candidateURL.User == nil && candidateURL.Fragment == ""
}
func absoluteHTTPURL(raw string) bool {
parsed, err := url.Parse(raw)
return err == nil && parsed.IsAbs() && parsed.Hostname() != "" && (parsed.Scheme == "https" || parsed.Scheme == "http") && parsed.User == nil && parsed.Fragment == ""
}
func decodeCoordinate(raw string) (*big.Int, error) {
value, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil || len(value) != 32 {
return nil, errors.New("invalid EC coordinate")
}
return new(big.Int).SetBytes(value), nil
}
func stringClaim(claims jwt.MapClaims, name string) string { return stringValue(claims[name]) }
func stringValue(value any) string {
result, _ := value.(string)
return result
}
func unixClaim(value any) time.Time {
switch typed := value.(type) {
case float64:
return time.Unix(int64(typed), 0).UTC()
case json.Number:
integer, err := typed.Int64()
if err == nil {
return time.Unix(integer, 0).UTC()
}
}
return time.Time{}
}
func validInitiator(value string) bool {
return value == "admin" || value == "user" || value == "policy" || value == "system"
}

View File

@ -0,0 +1,141 @@
package store
import (
"context"
"errors"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/google/uuid"
)
func TestDisableActiveIdentityRevisionQueuesSecretsAfterRuntimeRetirementAndClearsReferences(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
seedIdentityActivationPrerequisites(t, ctx, db, "default")
revisionID := seedIdentityLifecycleRevision(t, ctx, db, identity.RevisionActive, "default")
machineReference := "identity-machine-disable-" + uuid.NewString()
sessionReference := "identity-session-disable-" + uuid.NewString()
if _, err := db.pool.Exec(ctx, `UPDATE gateway_identity_configuration_revisions
SET machine_credential_ref=$2,session_encryption_key_ref=$3 WHERE id=$1::uuid`,
revisionID, machineReference, sessionReference); err != nil {
t.Fatalf("seed active identity Secret references: %v", err)
}
if err := db.QueueIdentitySecretCleanup(ctx, machineReference, time.Now().Add(-time.Minute)); err != nil {
t.Fatalf("seed prematurely due machine Secret cleanup: %v", err)
}
disableStartedAt := time.Now().UTC()
disabled, err := db.DisableActiveIdentityRevision(ctx, 1, "trace-disable-secrets", "audit-disable-secrets")
if err != nil {
t.Fatalf("disable active identity revision: %v", err)
}
if disabled.State != identity.RevisionSuperseded || disabled.Version != 2 ||
disabled.MachineCredentialRef != "" || disabled.SessionEncryptionKeyRef != "" {
t.Fatalf("disabled revision retained Secret references: %#v", disabled)
}
persisted, err := db.IdentityConfigurationRevision(ctx, revisionID)
if err != nil {
t.Fatalf("read disabled identity revision: %v", err)
}
if persisted.State != identity.RevisionSuperseded || persisted.MachineCredentialRef != "" ||
persisted.SessionEncryptionKeyRef != "" {
t.Fatalf("persisted disabled revision retained Secret references: %#v", persisted)
}
type queuedSecret struct {
reference string
status string
notBefore time.Time
}
rows, err := db.pool.Query(ctx, `SELECT secret_ref,status,not_before
FROM gateway_identity_secret_cleanup_queue ORDER BY secret_ref`)
if err != nil {
t.Fatalf("read disabled identity Secret cleanup queue: %v", err)
}
defer rows.Close()
queued := make(map[string]queuedSecret, 2)
for rows.Next() {
var secret queuedSecret
if err := rows.Scan(&secret.reference, &secret.status, &secret.notBefore); err != nil {
t.Fatalf("scan disabled identity Secret cleanup: %v", err)
}
queued[secret.reference] = secret
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate disabled identity Secret cleanup queue: %v", err)
}
if len(queued) != 2 {
t.Fatalf("disabled identity cleanup queue entries=%v, want both Secret references", queued)
}
minimumSafeCleanup := disableStartedAt.Add(45 * time.Second)
for _, reference := range []string{machineReference, sessionReference} {
secret, ok := queued[reference]
if !ok || secret.status != "pending" || secret.notBefore.Before(minimumSafeCleanup) {
t.Fatalf("queued Secret %q=%#v present=%t, want pending cleanup no earlier than %s",
reference, secret, ok, minimumSafeCleanup)
}
}
claims, err := db.ClaimIdentitySecretCleanups(ctx, 10, time.Minute)
if err != nil {
t.Fatalf("claim not-yet-due disabled identity Secrets: %v", err)
}
if len(claims) != 0 {
t.Fatalf("disabled identity Secrets became claimable before Runtime retirement: %#v", claims)
}
}
func TestDisableActiveIdentityRevisionCleanupConflictRollsBackStateReferencesAndQueue(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
seedIdentityActivationPrerequisites(t, ctx, db, "default")
revisionID := seedIdentityLifecycleRevision(t, ctx, db, identity.RevisionActive, "default")
machineReference := "identity-machine-disable-conflict-" + uuid.NewString()
sessionReference := "identity-session-disable-conflict-" + uuid.NewString()
if _, err := db.pool.Exec(ctx, `UPDATE gateway_identity_configuration_revisions
SET machine_credential_ref=$2,session_encryption_key_ref=$3 WHERE id=$1::uuid`,
revisionID, machineReference, sessionReference); err != nil {
t.Fatalf("seed conflicting active identity Secret references: %v", err)
}
if err := db.QueueIdentitySecretCleanup(ctx, machineReference, time.Now().Add(-time.Minute)); err != nil {
t.Fatalf("queue machine Secret before conflict claim: %v", err)
}
claims, err := db.ClaimIdentitySecretCleanups(ctx, 1, time.Minute)
if err != nil || len(claims) != 1 || claims[0].Reference != machineReference {
t.Fatalf("claim machine Secret before disable: claims=%#v error=%v", claims, err)
}
_, err = db.DisableActiveIdentityRevision(ctx, 1, "trace-disable-conflict", "audit-disable-conflict")
if !errors.Is(err, ErrIdentitySecretCleanupConflict) {
t.Fatalf("disable with claimed Secret cleanup error=%v, want cleanup conflict", err)
}
persisted, err := db.IdentityConfigurationRevision(ctx, revisionID)
if err != nil {
t.Fatalf("read active identity revision after cleanup conflict: %v", err)
}
if persisted.State != identity.RevisionActive || persisted.Version != 1 ||
persisted.MachineCredentialRef != machineReference || persisted.SessionEncryptionKeyRef != sessionReference {
t.Fatalf("cleanup conflict partially disabled identity revision: %#v", persisted)
}
var status, claimToken string
if err := db.pool.QueryRow(ctx, `SELECT status,claim_token::text FROM gateway_identity_secret_cleanup_queue
WHERE secret_ref=$1`, machineReference).Scan(&status, &claimToken); err != nil {
t.Fatalf("read claimed machine Secret after disable rollback: %v", err)
}
if status != "claimed" || claimToken != claims[0].ClaimToken {
t.Fatalf("disable rollback changed claimed cleanup status=%q token=%q", status, claimToken)
}
var sessionQueueEntries int
if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_identity_secret_cleanup_queue
WHERE secret_ref=$1`, sessionReference).Scan(&sessionQueueEntries); err != nil {
t.Fatalf("count session Secret cleanup after disable rollback: %v", err)
}
if sessionQueueEntries != 0 {
t.Fatalf("disable cleanup conflict left %d partial session cleanup entries", sessionQueueEntries)
}
}

View File

@ -0,0 +1,460 @@
package store
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/jackc/pgx/v5"
)
var ErrIdentityManagementRequestNotFound = errors.New("identity management request not found")
type IdentityManagementRequest struct {
Operation string
Key string
RequestHash string
Response []byte
CreatedAt time.Time
}
const identityRevisionColumns = `
id::text,state,schema_version,auth_center_url,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(application_id,''),
COALESCE(audience,''),COALESCE(browser_client_id,''),COALESCE(machine_client_id,''),scopes,capabilities,role_prefix,
local_tenant_key,public_base_url,web_base_url,jit_enabled,legacy_jwt_enabled,token_introspection,session_revocation,
COALESCE(security_event_issuer,''),COALESCE(security_event_configuration_url,''),COALESCE(security_event_audience,''),
COALESCE(machine_credential_ref,''),COALESCE(session_encryption_key_ref,''),session_idle_seconds,session_absolute_seconds,
session_refresh_seconds,version,COALESCE(last_error_category,''),COALESCE(last_trace_id,''),COALESCE(last_audit_id,''),
validated_at,activated_at,superseded_at,created_at,updated_at`
// identityConfigurationLifecycleLockID serializes the database-side boundary
// between onboarding reservations and Active Revision mutations. A transaction
// must hold this lock before deciding that no Active configuration or foreign
// pairing reservation exists.
const identityConfigurationLifecycleLockID int64 = 0x4541494944454e54
// identityDisabledSecretCleanupDelay is intentionally longer than the
// IdentityRuntimeManager's 30-second old-Runtime retirement window. Starting
// cleanup one minute after the database handoff leaves an additional 30
// seconds for commit, Runtime publication, timer scheduling, and worker jitter.
const identityDisabledSecretCleanupDelay = time.Minute
func lockIdentityConfigurationLifecycle(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, identityConfigurationLifecycleLockID)
return err
}
// beginIdentityConfigurationLifecycleTx deliberately uses READ COMMITTED.
// PostgreSQL fixes the transaction snapshot when a SERIALIZABLE transaction
// executes the advisory-lock SELECT, before that statement finishes waiting.
// A later state check can therefore miss the preceding lock holder's commit.
// READ COMMITTED gives every statement after lock acquisition a fresh snapshot,
// while the transaction-scoped advisory lock serializes all lifecycle decisions.
func (s *Store) beginIdentityConfigurationLifecycleTx(ctx context.Context) (pgx.Tx, error) {
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted})
if err != nil {
return nil, err
}
if err := lockIdentityConfigurationLifecycle(ctx, tx); err != nil {
_ = tx.Rollback(ctx)
return nil, err
}
return tx, nil
}
func queueDisabledIdentitySecretCleanupTx(ctx context.Context, tx pgx.Tx, reference string, notBefore time.Time) error {
tag, err := tx.Exec(ctx, `INSERT INTO gateway_identity_secret_cleanup_queue(secret_ref,not_before)
VALUES($1,$2) ON CONFLICT(secret_ref) DO UPDATE
SET not_before=GREATEST(gateway_identity_secret_cleanup_queue.not_before,EXCLUDED.not_before),updated_at=now()
WHERE gateway_identity_secret_cleanup_queue.status='pending'`, reference, notBefore)
if err != nil {
return err
}
if tag.RowsAffected() != 1 {
return ErrIdentitySecretCleanupConflict
}
return nil
}
func (s *Store) CreateIdentityConfigurationRevision(ctx context.Context, revision identity.Revision) (identity.Revision, error) {
scopes, _ := json.Marshal(revision.Scopes)
capabilities, _ := json.Marshal(revision.Capabilities)
return scanIdentityRevision(s.pool.QueryRow(ctx, `
INSERT INTO gateway_identity_configuration_revisions (
id,state,schema_version,auth_center_url,role_prefix,local_tenant_key,public_base_url,web_base_url,
jit_enabled,legacy_jwt_enabled,scopes,capabilities,session_idle_seconds,session_absolute_seconds,session_refresh_seconds
) VALUES ($1::uuid,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12::jsonb,$13,$14,$15)
RETURNING `+identityRevisionColumns,
revision.ID, revision.State, revision.SchemaVersion, revision.AuthCenterURL, revision.RolePrefix,
revision.LocalTenantKey, revision.PublicBaseURL, revision.WebBaseURL, revision.JITEnabled, revision.LegacyJWTEnabled,
string(scopes), string(capabilities), revision.SessionIdleSeconds, revision.SessionAbsoluteSeconds, revision.SessionRefreshSeconds,
))
}
func (s *Store) IdentityConfigurationRevision(ctx context.Context, id string) (identity.Revision, error) {
revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `SELECT `+identityRevisionColumns+`
FROM gateway_identity_configuration_revisions WHERE id=$1::uuid`, id))
return revision, normalizeIdentityRevisionError(err)
}
func (s *Store) ActiveIdentityConfigurationRevision(ctx context.Context) (identity.Revision, error) {
revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `SELECT `+identityRevisionColumns+`
FROM gateway_identity_configuration_revisions WHERE state='active'`))
return revision, normalizeIdentityRevisionError(err)
}
func (s *Store) LatestInactiveIdentityConfigurationRevision(ctx context.Context) (identity.Revision, error) {
revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `SELECT `+identityRevisionColumns+`
FROM gateway_identity_configuration_revisions
WHERE id=(
SELECT revision.id
FROM gateway_identity_configuration_revisions revision
JOIN gateway_identity_onboarding_exchanges exchange ON exchange.revision_id=revision.id
WHERE revision.state IN ('draft','validated','failed')
ORDER BY NOT (exchange.status='cancelled' AND exchange.cleanup_status='completed') DESC,
revision.created_at DESC,exchange.created_at DESC
LIMIT 1
)`))
return revision, normalizeIdentityRevisionError(err)
}
func (s *Store) LatestSupersededIdentityConfigurationRevision(ctx context.Context) (identity.Revision, error) {
revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `SELECT `+identityRevisionColumns+`
FROM gateway_identity_configuration_revisions WHERE state='superseded' ORDER BY superseded_at DESC NULLS LAST LIMIT 1`))
return revision, normalizeIdentityRevisionError(err)
}
func (s *Store) UpdateIdentityRevisionPolicy(ctx context.Context, id string, expectedVersion int64, policy identity.RevisionPolicy) (identity.Revision, error) {
if err := policy.Validate(); err != nil {
return identity.Revision{}, err
}
revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `
UPDATE gateway_identity_configuration_revisions SET local_tenant_key=$3,role_prefix=$4,jit_enabled=$5,
legacy_jwt_enabled=$6,session_idle_seconds=$7,session_absolute_seconds=$8,session_refresh_seconds=$9,
version=version+1,updated_at=now()
WHERE id=$1::uuid AND version=$2 AND state='draft'
RETURNING `+identityRevisionColumns, id, expectedVersion, policy.LocalTenantKey, policy.RolePrefix, policy.JITEnabled,
policy.LegacyJWTEnabled, policy.SessionIdleSeconds, policy.SessionAbsoluteSeconds, policy.SessionRefreshSeconds))
if errors.Is(err, pgx.ErrNoRows) {
return identity.Revision{}, identity.ErrRevisionConflict
}
return revision, err
}
func (s *Store) IdentityManagementRequest(ctx context.Context, operation, key string) (IdentityManagementRequest, error) {
var request IdentityManagementRequest
err := s.pool.QueryRow(ctx, `SELECT operation,idempotency_key,request_hash,response,created_at
FROM gateway_identity_management_requests WHERE operation=$1 AND idempotency_key=$2`, operation, key).Scan(
&request.Operation, &request.Key, &request.RequestHash, &request.Response, &request.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return IdentityManagementRequest{}, ErrIdentityManagementRequestNotFound
}
return request, err
}
func (s *Store) RecordIdentityManagementRequest(ctx context.Context, request IdentityManagementRequest) error {
if !json.Valid(request.Response) {
return errors.New("identity management response is invalid")
}
_, err := s.pool.Exec(ctx, `INSERT INTO gateway_identity_management_requests(operation,idempotency_key,request_hash,response)
VALUES($1,$2,$3,$4::jsonb) ON CONFLICT(operation,idempotency_key) DO NOTHING`,
request.Operation, request.Key, request.RequestHash, request.Response)
return err
}
func (s *Store) ApplyIdentityManifest(ctx context.Context, id string, expectedVersion int64, applied identity.ManifestApplication) (identity.Revision, error) {
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
if err != nil {
return identity.Revision{}, err
}
defer tx.Rollback(ctx)
current, err := scanIdentityRevision(tx.QueryRow(ctx, `SELECT `+identityRevisionColumns+`
FROM gateway_identity_configuration_revisions WHERE id=$1::uuid FOR UPDATE`, id))
if err != nil {
return identity.Revision{}, normalizeIdentityRevisionError(err)
}
if current.Version != expectedVersion {
return identity.Revision{}, identity.ErrRevisionConflict
}
updated, err := identity.ApplyManifest(current, applied)
if err != nil {
return identity.Revision{}, err
}
newReferences := make([]string, 0, 2)
for _, reference := range []string{updated.MachineCredentialRef, updated.SessionEncryptionKeyRef} {
if reference != "" {
newReferences = append(newReferences, reference)
}
}
if len(newReferences) > 0 {
if err := adoptPendingIdentitySecrets(ctx, tx, newReferences...); err != nil {
return identity.Revision{}, err
}
}
scopes, _ := json.Marshal(updated.Scopes)
capabilities, _ := json.Marshal(updated.Capabilities)
revision, err := scanIdentityRevision(tx.QueryRow(ctx, `
UPDATE gateway_identity_configuration_revisions SET
issuer=$3,tenant_id=$4,application_id=$5,audience=NULLIF($6,''),browser_client_id=NULLIF($7,''),machine_client_id=NULLIF($8,''),
scopes=$9::jsonb,capabilities=$10::jsonb,token_introspection=$11,session_revocation=$12,
security_event_issuer=NULLIF($13,''),security_event_configuration_url=NULLIF($14,''),security_event_audience=NULLIF($15,''),
machine_credential_ref=NULLIF($16,''),session_encryption_key_ref=NULLIF($17,''),last_trace_id=NULLIF($18,''),
last_audit_id=NULLIF($19,''),last_error_category=NULL,version=version+1,updated_at=now()
WHERE id=$1::uuid AND version=$2 AND state='draft'
RETURNING `+identityRevisionColumns,
id, expectedVersion, updated.Issuer, updated.TenantID, updated.ApplicationID, updated.Audience,
updated.BrowserClientID, updated.MachineClientID, string(scopes), string(capabilities), updated.TokenIntrospection,
updated.SessionRevocation, updated.SecurityEventIssuer, updated.SecurityEventConfigURL, updated.SecurityEventAudience,
updated.MachineCredentialRef, updated.SessionEncryptionKeyRef, updated.LastTraceID, updated.LastAuditID,
))
if errors.Is(err, pgx.ErrNoRows) {
return identity.Revision{}, identity.ErrRevisionConflict
}
if err != nil {
return identity.Revision{}, err
}
activeReferences := map[string]struct{}{}
for _, reference := range newReferences {
activeReferences[reference] = struct{}{}
}
for _, reference := range []string{current.MachineCredentialRef, current.SessionEncryptionKeyRef} {
if reference == "" {
continue
}
if _, stillActive := activeReferences[reference]; stillActive {
continue
}
if err := queueIdentitySecretCleanupTx(ctx, tx, reference, time.Now().UTC()); err != nil {
return identity.Revision{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return identity.Revision{}, err
}
return revision, nil
}
func (s *Store) MarkIdentityRevisionValidated(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `
UPDATE gateway_identity_configuration_revisions SET state='validated',validated_at=now(),last_error_category=NULL,
last_trace_id=NULLIF($3,''),last_audit_id=NULLIF($4,''),version=version+1,updated_at=now()
WHERE id=$1::uuid AND version=$2 AND state='draft'
RETURNING `+identityRevisionColumns, id, expectedVersion, traceID, auditID))
if errors.Is(err, pgx.ErrNoRows) {
return identity.Revision{}, identity.ErrRevisionConflict
}
return revision, err
}
func (s *Store) RevalidateActiveIdentityRevision(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `
UPDATE gateway_identity_configuration_revisions SET validated_at=now(),last_error_category=NULL,
last_trace_id=NULLIF($3,''),last_audit_id=NULLIF($4,''),version=version+1,updated_at=now()
WHERE id=$1::uuid AND version=$2 AND state='active'
RETURNING `+identityRevisionColumns, id, expectedVersion, traceID, auditID))
if errors.Is(err, pgx.ErrNoRows) {
return identity.Revision{}, identity.ErrRevisionConflict
}
return revision, err
}
func (s *Store) MarkIdentityRevisionFailed(ctx context.Context, id string, expectedVersion int64, category, traceID, auditID string) (identity.Revision, error) {
revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `
UPDATE gateway_identity_configuration_revisions SET state='failed',last_error_category=NULLIF($3,''),
last_trace_id=NULLIF($4,''),last_audit_id=NULLIF($5,''),version=version+1,updated_at=now()
WHERE id=$1::uuid AND version=$2 AND state IN ('draft','validated')
RETURNING `+identityRevisionColumns, id, expectedVersion, category, traceID, auditID))
if errors.Is(err, pgx.ErrNoRows) {
return identity.Revision{}, identity.ErrRevisionConflict
}
return revision, err
}
func (s *Store) ActivateIdentityRevision(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, bool, error) {
tx, err := s.beginIdentityConfigurationLifecycleTx(ctx)
if err != nil {
return identity.Revision{}, false, err
}
defer tx.Rollback(ctx)
var foreignPairingReservation bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(
SELECT 1 FROM gateway_identity_pairing_start_reservation
WHERE (state='starting' AND expires_at > now())
OR (state='paired' AND revision_id IS DISTINCT FROM $1::uuid)
)`, id).Scan(&foreignPairingReservation); err != nil {
return identity.Revision{}, false, err
}
if foreignPairingReservation {
return identity.Revision{}, false, identity.ErrPairingInProgress
}
if ok, err := hasBreakGlassManager(ctx, tx); err != nil {
return identity.Revision{}, false, err
} else if !ok {
return identity.Revision{}, false, identity.ErrBreakGlassRequired
}
var tenantExists bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway_tenants WHERE tenant_key=(
SELECT local_tenant_key FROM gateway_identity_configuration_revisions WHERE id=$1::uuid) AND status='active')`, id).Scan(&tenantExists); err != nil {
return identity.Revision{}, false, err
}
if !tenantExists {
return identity.Revision{}, false, identity.ErrLocalTenantInvalid
}
var previousID, previousIssuer, previousTenant, previousAudience, previousClient, previousSessionRef string
if err := tx.QueryRow(ctx, `SELECT id::text,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(audience,''),
COALESCE(browser_client_id,''),COALESCE(session_encryption_key_ref,'') FROM gateway_identity_configuration_revisions
WHERE state='active' FOR UPDATE`).Scan(
&previousID, &previousIssuer, &previousTenant, &previousAudience, &previousClient, &previousSessionRef,
); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return identity.Revision{}, false, err
}
if previousID != "" && previousID != id {
return identity.Revision{}, false, identity.ErrActiveConfigurationHandoffRequired
}
var nextIssuer, nextTenant, nextAudience, nextClient, nextSessionRef string
if err := tx.QueryRow(ctx, `SELECT COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(audience,''),
COALESCE(browser_client_id,''),COALESCE(session_encryption_key_ref,'') FROM gateway_identity_configuration_revisions
WHERE id=$1::uuid AND version=$2 AND state='validated' FOR UPDATE`, id, expectedVersion).Scan(
&nextIssuer, &nextTenant, &nextAudience, &nextClient, &nextSessionRef,
); errors.Is(err, pgx.ErrNoRows) {
return identity.Revision{}, false, identity.ErrRevisionConflict
} else if err != nil {
return identity.Revision{}, false, err
}
if previousID != "" {
if _, err := tx.Exec(ctx, `UPDATE gateway_identity_configuration_revisions SET state='superseded',superseded_at=now(),
version=version+1,updated_at=now() WHERE id=$1::uuid AND state='active'`, previousID); err != nil {
return identity.Revision{}, false, err
}
}
revision, err := scanIdentityRevision(tx.QueryRow(ctx, `UPDATE gateway_identity_configuration_revisions SET state='active',
activated_at=now(),superseded_at=NULL,last_trace_id=NULLIF($3,''),last_audit_id=NULLIF($4,''),version=version+1,updated_at=now()
WHERE id=$1::uuid AND version=$2 AND state='validated' RETURNING `+identityRevisionColumns, id, expectedVersion, traceID, auditID))
if err != nil {
return identity.Revision{}, false, err
}
identityChanged := previousID != "" && (previousIssuer != nextIssuer || previousTenant != nextTenant || previousAudience != nextAudience || previousClient != nextClient || previousSessionRef != nextSessionRef)
if identityChanged {
if _, err := tx.Exec(ctx, `DELETE FROM gateway_oidc_sessions`); err != nil {
return identity.Revision{}, false, err
}
}
if _, err := tx.Exec(ctx, `DELETE FROM gateway_identity_pairing_start_reservation WHERE revision_id=$1::uuid`, id); err != nil {
return identity.Revision{}, false, err
}
if err := tx.Commit(ctx); err != nil {
return identity.Revision{}, false, err
}
return revision, identityChanged, nil
}
func (s *Store) DisableActiveIdentityRevision(ctx context.Context, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
tx, err := s.beginIdentityConfigurationLifecycleTx(ctx)
if err != nil {
return identity.Revision{}, err
}
defer tx.Rollback(ctx)
if ok, err := hasBreakGlassManager(ctx, tx); err != nil {
return identity.Revision{}, err
} else if !ok {
return identity.Revision{}, identity.ErrBreakGlassRequired
}
var activeID, machineReference, sessionReference string
if err := tx.QueryRow(ctx, `SELECT id::text,COALESCE(machine_credential_ref,''),COALESCE(session_encryption_key_ref,'')
FROM gateway_identity_configuration_revisions
WHERE state='active' AND version=$1 FOR UPDATE`, expectedVersion).Scan(
&activeID, &machineReference, &sessionReference,
); errors.Is(err, pgx.ErrNoRows) {
return identity.Revision{}, identity.ErrRevisionConflict
} else if err != nil {
return identity.Revision{}, err
}
cleanupNotBefore := time.Now().UTC().Add(identityDisabledSecretCleanupDelay)
references := make(map[string]struct{}, 2)
for _, reference := range []string{machineReference, sessionReference} {
if reference != "" {
references[reference] = struct{}{}
}
}
for reference := range references {
if err := queueDisabledIdentitySecretCleanupTx(ctx, tx, reference, cleanupNotBefore); err != nil {
return identity.Revision{}, err
}
}
revision, err := scanIdentityRevision(tx.QueryRow(ctx, `UPDATE gateway_identity_configuration_revisions SET state='superseded',
superseded_at=now(),machine_credential_ref=NULL,session_encryption_key_ref=NULL,
last_trace_id=NULLIF($3,''),last_audit_id=NULLIF($4,''),version=version+1,updated_at=now()
WHERE id=$1::uuid AND state='active' AND version=$2
RETURNING `+identityRevisionColumns, activeID, expectedVersion, traceID, auditID))
if errors.Is(err, pgx.ErrNoRows) {
return identity.Revision{}, identity.ErrRevisionConflict
}
if err != nil {
return identity.Revision{}, err
}
if _, err := tx.Exec(ctx, `DELETE FROM gateway_oidc_sessions`); err != nil {
return identity.Revision{}, err
}
if err := tx.Commit(ctx); err != nil {
return identity.Revision{}, err
}
return revision, nil
}
func (s *Store) HasBreakGlassManager(ctx context.Context) (bool, error) {
return hasBreakGlassManager(ctx, s.pool)
}
func (s *Store) HasActiveTenantKey(ctx context.Context, tenantKey string) (bool, error) {
var exists bool
err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway_tenants WHERE tenant_key=$1 AND status='active')`, tenantKey).Scan(&exists)
return exists, err
}
func hasBreakGlassManager(ctx context.Context, query interface {
QueryRow(context.Context, string, ...any) pgx.Row
}) (bool, error) {
var exists bool
err := query.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway_users WHERE source='gateway' AND status='active'
AND deleted_at IS NULL AND password_hash IS NOT NULL AND password_hash <> '' AND (roles ? 'manager' OR roles ? 'admin'))`).Scan(&exists)
return exists, err
}
func scanIdentityRevision(row scanner) (identity.Revision, error) {
var revision identity.Revision
var state string
var scopes, capabilities []byte
if err := row.Scan(
&revision.ID, &state, &revision.SchemaVersion, &revision.AuthCenterURL, &revision.Issuer, &revision.TenantID,
&revision.ApplicationID, &revision.Audience, &revision.BrowserClientID, &revision.MachineClientID, &scopes,
&capabilities, &revision.RolePrefix, &revision.LocalTenantKey, &revision.PublicBaseURL, &revision.WebBaseURL,
&revision.JITEnabled, &revision.LegacyJWTEnabled, &revision.TokenIntrospection, &revision.SessionRevocation,
&revision.SecurityEventIssuer, &revision.SecurityEventConfigURL, &revision.SecurityEventAudience,
&revision.MachineCredentialRef, &revision.SessionEncryptionKeyRef, &revision.SessionIdleSeconds,
&revision.SessionAbsoluteSeconds, &revision.SessionRefreshSeconds, &revision.Version, &revision.LastErrorCategory,
&revision.LastTraceID, &revision.LastAuditID, &revision.ValidatedAt, &revision.ActivatedAt, &revision.SupersededAt,
&revision.CreatedAt, &revision.UpdatedAt,
); err != nil {
return identity.Revision{}, err
}
revision.State = identity.RevisionState(state)
_ = json.Unmarshal(scopes, &revision.Scopes)
_ = json.Unmarshal(capabilities, &revision.Capabilities)
if revision.Scopes == nil {
revision.Scopes = []string{}
}
if revision.Capabilities == nil {
revision.Capabilities = []string{}
}
return revision, nil
}
func normalizeIdentityRevisionError(err error) error {
if errors.Is(err, pgx.ErrNoRows) {
return identity.ErrRevisionNotFound
}
return err
}

View File

@ -0,0 +1,407 @@
package store
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/jackc/pgx/v5"
)
const identityPairingColumns = `
id::text,revision_id::text,remote_exchange_id::text,exchange_token_ref,status,cleanup_status,remote_version,expires_at,version,
COALESCE(last_error_category,''),COALESCE(auth_center_audit_id,''),COALESCE(last_trace_id,''),cancelled_at,cleanup_completed_at,
created_at,updated_at`
func (s *Store) ReserveIdentityPairingStart(ctx context.Context, attemptID string, expiresAt time.Time) error {
tx, err := s.beginIdentityConfigurationLifecycleTx(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `DELETE FROM gateway_identity_pairing_start_reservation
WHERE state='starting' AND expires_at <= now()`); err != nil {
return err
}
var activeConfigurationExists bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(
SELECT 1 FROM gateway_identity_configuration_revisions
WHERE state='active'
)`).Scan(&activeConfigurationExists); err != nil {
return err
}
if activeConfigurationExists {
return identity.ErrActiveConfigurationHandoffRequired
}
var blocked bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(
SELECT 1 FROM gateway_identity_configuration_revisions revision
JOIN gateway_identity_onboarding_exchanges exchange ON exchange.revision_id=revision.id
WHERE revision.state IN ('draft','validated','failed')
AND NOT (exchange.status='cancelled' AND exchange.cleanup_status='completed')
)`).Scan(&blocked); err != nil {
return err
}
if blocked {
return identity.ErrPairingInProgress
}
if _, err := tx.Exec(ctx, `INSERT INTO gateway_identity_pairing_start_reservation(singleton,attempt_id,state,expires_at)
VALUES(true,$1::uuid,'starting',$2)`, attemptID, expiresAt); err != nil {
if isUniqueViolation(err) {
return identity.ErrPairingInProgress
}
return err
}
return tx.Commit(ctx)
}
func (s *Store) ReleaseIdentityPairingStart(ctx context.Context, attemptID string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM gateway_identity_pairing_start_reservation
WHERE attempt_id=$1::uuid AND state='starting'`, attemptID)
return err
}
func (s *Store) CommitIdentityPairingStart(ctx context.Context, revision identity.Revision, exchange identity.PairingExchange) (identity.PairingExchange, error) {
tx, err := s.beginIdentityConfigurationLifecycleTx(ctx)
if err != nil {
return identity.PairingExchange{}, err
}
defer tx.Rollback(ctx)
var reserved bool
if err := tx.QueryRow(ctx, `SELECT true FROM gateway_identity_pairing_start_reservation
WHERE singleton=true AND attempt_id=$1::uuid AND state='starting' FOR UPDATE`, exchange.ID).Scan(&reserved); errors.Is(err, pgx.ErrNoRows) {
return identity.PairingExchange{}, identity.ErrPairingInProgress
} else if err != nil {
return identity.PairingExchange{}, err
}
if !reserved {
return identity.PairingExchange{}, identity.ErrPairingInProgress
}
var activeConfigurationExists bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(
SELECT 1 FROM gateway_identity_configuration_revisions WHERE state='active'
)`).Scan(&activeConfigurationExists); err != nil {
return identity.PairingExchange{}, err
}
if activeConfigurationExists {
return identity.PairingExchange{}, identity.ErrActiveConfigurationHandoffRequired
}
if err := adoptPendingIdentitySecrets(ctx, tx, exchange.ExchangeTokenRef); err != nil {
return identity.PairingExchange{}, err
}
scopes, _ := json.Marshal(revision.Scopes)
capabilities, _ := json.Marshal(revision.Capabilities)
if _, err := tx.Exec(ctx, `INSERT INTO gateway_identity_configuration_revisions (
id,state,schema_version,auth_center_url,role_prefix,local_tenant_key,public_base_url,web_base_url,
jit_enabled,legacy_jwt_enabled,scopes,capabilities,session_idle_seconds,session_absolute_seconds,session_refresh_seconds,last_trace_id
) VALUES ($1::uuid,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12::jsonb,$13,$14,$15,NULLIF($16,''))`,
revision.ID, revision.State, revision.SchemaVersion, revision.AuthCenterURL, revision.RolePrefix,
revision.LocalTenantKey, revision.PublicBaseURL, revision.WebBaseURL, revision.JITEnabled, revision.LegacyJWTEnabled,
string(scopes), string(capabilities), revision.SessionIdleSeconds, revision.SessionAbsoluteSeconds,
revision.SessionRefreshSeconds, revision.LastTraceID,
); err != nil {
return identity.PairingExchange{}, err
}
created, err := scanIdentityPairing(tx.QueryRow(ctx, `INSERT INTO gateway_identity_onboarding_exchanges (
id,revision_id,remote_exchange_id,exchange_token_ref,status,cleanup_status,remote_version,expires_at,last_trace_id
) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,$8,NULLIF($9,''))
RETURNING `+identityPairingColumns,
exchange.ID, revision.ID, exchange.RemoteExchangeID, exchange.ExchangeTokenRef,
exchange.Status, exchange.CleanupStatus, exchange.RemoteVersion, exchange.ExpiresAt, exchange.LastTraceID,
))
if err != nil {
return identity.PairingExchange{}, err
}
tag, err := tx.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation
SET state='paired',revision_id=$2::uuid,expires_at=$3,updated_at=now()
WHERE attempt_id=$1::uuid AND state='starting'`, exchange.ID, revision.ID, exchange.ExpiresAt)
if err != nil {
return identity.PairingExchange{}, err
}
if tag.RowsAffected() != 1 {
return identity.PairingExchange{}, identity.ErrPairingInProgress
}
if err := tx.Commit(ctx); err != nil {
return identity.PairingExchange{}, err
}
return created, nil
}
func (s *Store) IdentityPairingStartBlocked(ctx context.Context) (bool, error) {
var blocked bool
err := s.pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM gateway_identity_configuration_revisions revision
JOIN gateway_identity_onboarding_exchanges exchange ON exchange.revision_id=revision.id
WHERE revision.state IN ('draft','validated','failed')
AND NOT (exchange.status='cancelled' AND exchange.cleanup_status='completed')
) OR EXISTS (
SELECT 1 FROM gateway_identity_pairing_start_reservation
WHERE state='paired' OR expires_at > now()
)`).Scan(&blocked)
return blocked, err
}
func (s *Store) CreateIdentityPairingExchange(ctx context.Context, exchange identity.PairingExchange) (identity.PairingExchange, error) {
return scanIdentityPairing(s.pool.QueryRow(ctx, `
INSERT INTO gateway_identity_onboarding_exchanges (
id,revision_id,remote_exchange_id,exchange_token_ref,status,cleanup_status,remote_version,expires_at,last_trace_id
) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,$8,NULLIF($9,''))
RETURNING `+identityPairingColumns,
exchange.ID, exchange.RevisionID, exchange.RemoteExchangeID, exchange.ExchangeTokenRef,
exchange.Status, exchange.CleanupStatus, exchange.RemoteVersion, exchange.ExpiresAt, exchange.LastTraceID,
))
}
func (s *Store) IdentityPairingExchange(ctx context.Context, id string) (identity.PairingExchange, error) {
exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, `SELECT `+identityPairingColumns+`
FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid`, id))
if errors.Is(err, pgx.ErrNoRows) {
return identity.PairingExchange{}, identity.ErrRevisionNotFound
}
return exchange, err
}
func (s *Store) IdentityPairingExchangeForRevision(ctx context.Context, revisionID string) (identity.PairingExchange, error) {
exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, `SELECT `+identityPairingColumns+`
FROM gateway_identity_onboarding_exchanges WHERE revision_id=$1::uuid`, revisionID))
if errors.Is(err, pgx.ErrNoRows) {
return identity.PairingExchange{}, identity.ErrRevisionNotFound
}
return exchange, err
}
func (s *Store) PendingIdentityPairingExchanges(ctx context.Context) ([]identity.PairingExchange, error) {
rows, err := s.pool.Query(ctx, `SELECT `+identityPairingColumns+`
FROM gateway_identity_onboarding_exchanges exchange
WHERE exchange.id=(
SELECT candidate_exchange.id
FROM gateway_identity_onboarding_exchanges candidate_exchange
JOIN gateway_identity_configuration_revisions revision ON revision.id=candidate_exchange.revision_id
WHERE revision.state IN ('draft','validated','failed')
ORDER BY NOT (candidate_exchange.status='cancelled' AND candidate_exchange.cleanup_status='completed') DESC,
revision.created_at DESC,candidate_exchange.created_at DESC
LIMIT 1
)
AND exchange.status IN ('metadata_pending','preparing','ready','credentials_saved')`)
if err != nil {
return nil, err
}
defer rows.Close()
exchanges := make([]identity.PairingExchange, 0)
for rows.Next() {
exchange, scanErr := scanIdentityPairing(rows)
if scanErr != nil {
return nil, scanErr
}
exchanges = append(exchanges, exchange)
}
return exchanges, rows.Err()
}
func (s *Store) PendingIdentityPairingCleanups(ctx context.Context) ([]identity.PairingExchange, error) {
rows, err := s.pool.Query(ctx, `SELECT `+identityPairingColumns+`
FROM gateway_identity_onboarding_exchanges
WHERE status='cancelled' AND cleanup_status='pending' ORDER BY updated_at`)
if err != nil {
return nil, err
}
defer rows.Close()
exchanges := make([]identity.PairingExchange, 0)
for rows.Next() {
exchange, scanErr := scanIdentityPairing(rows)
if scanErr != nil {
return nil, scanErr
}
exchanges = append(exchanges, exchange)
}
return exchanges, rows.Err()
}
func (s *Store) CompletedIdentityPairingsAwaitingActivation(ctx context.Context) ([]identity.PairingExchange, error) {
rows, err := s.pool.Query(ctx, `SELECT `+identityPairingColumns+`
FROM gateway_identity_onboarding_exchanges exchange
WHERE exchange.id=(
SELECT candidate_exchange.id
FROM gateway_identity_onboarding_exchanges candidate_exchange
JOIN gateway_identity_configuration_revisions revision ON revision.id=candidate_exchange.revision_id
WHERE revision.state IN ('draft','validated','failed')
ORDER BY NOT (candidate_exchange.status='cancelled' AND candidate_exchange.cleanup_status='completed') DESC,
revision.created_at DESC,candidate_exchange.created_at DESC
LIMIT 1
)
AND exchange.status='completed' AND EXISTS (
SELECT 1 FROM gateway_identity_configuration_revisions revision
WHERE revision.id=exchange.revision_id AND revision.state IN ('draft','validated')
)`)
if err != nil {
return nil, err
}
defer rows.Close()
exchanges := make([]identity.PairingExchange, 0)
for rows.Next() {
exchange, scanErr := scanIdentityPairing(rows)
if scanErr != nil {
return nil, scanErr
}
exchanges = append(exchanges, exchange)
}
return exchanges, rows.Err()
}
func (s *Store) UpdateIdentityPairingExchange(ctx context.Context, id string, expectedVersion int64, update identity.PairingExchangeUpdate) (identity.PairingExchange, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return identity.PairingExchange{}, err
}
defer tx.Rollback(ctx)
exchange, err := scanIdentityPairing(tx.QueryRow(ctx, `
UPDATE gateway_identity_onboarding_exchanges SET status=$3,remote_version=$4,last_error_category=NULLIF($5,''),
auth_center_audit_id=COALESCE(NULLIF($6,''),auth_center_audit_id),version=version+1,updated_at=now()
WHERE id=$1::uuid AND version=$2
RETURNING `+identityPairingColumns,
id, expectedVersion, update.Status, update.RemoteVersion, update.LastErrorCategory, update.AuthCenterAuditID,
))
if errors.Is(err, pgx.ErrNoRows) {
return identity.PairingExchange{}, identity.ErrRevisionConflict
}
if err != nil {
return identity.PairingExchange{}, err
}
if update.Status == identity.PairingCompleted || update.Status == identity.PairingFailed || update.Status == identity.PairingExpired {
if err := queueIdentitySecretCleanupTx(ctx, tx, exchange.ExchangeTokenRef, time.Now().UTC()); err != nil {
return identity.PairingExchange{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return identity.PairingExchange{}, err
}
return exchange, nil
}
func (s *Store) RecordIdentityPairingRetryFailure(ctx context.Context, id string, status identity.PairingStatus, category string) (identity.PairingExchange, error) {
exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, `
UPDATE gateway_identity_onboarding_exchanges SET last_error_category=$3,updated_at=now()
WHERE id=$1::uuid AND status=$2 AND cleanup_status='none'
RETURNING `+identityPairingColumns, id, status, category))
if errors.Is(err, pgx.ErrNoRows) {
return identity.PairingExchange{}, identity.ErrRevisionConflict
}
return exchange, err
}
func (s *Store) CancelIdentityPairingExchange(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.PairingExchange, error) {
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
if err != nil {
return identity.PairingExchange{}, err
}
defer tx.Rollback(ctx)
exchange, err := scanIdentityPairing(tx.QueryRow(ctx, `SELECT `+identityPairingColumns+`
FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid FOR UPDATE`, id))
if errors.Is(err, pgx.ErrNoRows) {
return identity.PairingExchange{}, identity.ErrRevisionNotFound
}
if err != nil {
return identity.PairingExchange{}, err
}
if exchange.Status == identity.PairingCancelled {
return exchange, tx.Commit(ctx)
}
if exchange.Version != expectedVersion {
return identity.PairingExchange{}, identity.ErrRevisionConflict
}
revision, err := scanIdentityRevision(tx.QueryRow(ctx, `SELECT `+identityRevisionColumns+`
FROM gateway_identity_configuration_revisions WHERE id=$1::uuid FOR UPDATE`, exchange.RevisionID))
if err != nil {
return identity.PairingExchange{}, normalizeIdentityRevisionError(err)
}
if revision.State != identity.RevisionDraft && revision.State != identity.RevisionValidated && revision.State != identity.RevisionFailed {
return identity.PairingExchange{}, identity.ErrPairingNotCancellable
}
if _, err := tx.Exec(ctx, `UPDATE gateway_identity_configuration_revisions SET state='failed',
last_error_category='pairing_cancelled',last_trace_id=NULLIF($2,''),last_audit_id=NULLIF($3,''),
version=version+1,updated_at=now() WHERE id=$1::uuid`, exchange.RevisionID, traceID, auditID); err != nil {
return identity.PairingExchange{}, err
}
exchange, err = scanIdentityPairing(tx.QueryRow(ctx, `
UPDATE gateway_identity_onboarding_exchanges SET status='cancelled',cleanup_status='pending',cancelled_at=now(),
cleanup_completed_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid AND version=$2
RETURNING `+identityPairingColumns, id, expectedVersion))
if errors.Is(err, pgx.ErrNoRows) {
return identity.PairingExchange{}, identity.ErrRevisionConflict
}
if err != nil {
return identity.PairingExchange{}, err
}
if err := queueIdentitySecretCleanupTx(ctx, tx, exchange.ExchangeTokenRef, time.Now().UTC()); err != nil {
return identity.PairingExchange{}, err
}
if err := tx.Commit(ctx); err != nil {
return identity.PairingExchange{}, err
}
return exchange, nil
}
func (s *Store) CompleteIdentityPairingCleanup(ctx context.Context, id string, expectedVersion int64) (identity.PairingExchange, error) {
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
if err != nil {
return identity.PairingExchange{}, err
}
defer tx.Rollback(ctx)
var revisionID string
if err := tx.QueryRow(ctx, `SELECT revision_id::text FROM gateway_identity_onboarding_exchanges
WHERE id=$1::uuid AND version=$2 AND status='cancelled' AND cleanup_status='pending' FOR UPDATE`, id, expectedVersion).Scan(&revisionID); errors.Is(err, pgx.ErrNoRows) {
return identity.PairingExchange{}, identity.ErrRevisionConflict
} else if err != nil {
return identity.PairingExchange{}, err
}
tag, err := tx.Exec(ctx, `UPDATE gateway_identity_configuration_revisions SET machine_credential_ref=NULL,
session_encryption_key_ref=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid AND state='failed'`, revisionID)
if err != nil {
return identity.PairingExchange{}, err
}
if tag.RowsAffected() != 1 {
return identity.PairingExchange{}, identity.ErrRevisionConflict
}
exchange, err := scanIdentityPairing(tx.QueryRow(ctx, `UPDATE gateway_identity_onboarding_exchanges
SET cleanup_status='completed',cleanup_completed_at=now(),version=version+1,updated_at=now()
WHERE id=$1::uuid AND version=$2 AND status='cancelled' AND cleanup_status='pending'
RETURNING `+identityPairingColumns, id, expectedVersion))
if err != nil {
return identity.PairingExchange{}, err
}
if _, err := tx.Exec(ctx, `DELETE FROM gateway_identity_pairing_start_reservation WHERE revision_id=$1::uuid`, revisionID); err != nil {
return identity.PairingExchange{}, err
}
if err := tx.Commit(ctx); err != nil {
return identity.PairingExchange{}, err
}
return exchange, nil
}
func (s *Store) RecordIdentityPairingCleanupFailure(ctx context.Context, id, category string) (identity.PairingExchange, error) {
exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, `UPDATE gateway_identity_onboarding_exchanges
SET last_error_category=$2,updated_at=now() WHERE id=$1::uuid AND status='cancelled' AND cleanup_status='pending'
RETURNING `+identityPairingColumns, id, category))
if errors.Is(err, pgx.ErrNoRows) {
return identity.PairingExchange{}, identity.ErrRevisionConflict
}
return exchange, err
}
func scanIdentityPairing(row scanner) (identity.PairingExchange, error) {
var exchange identity.PairingExchange
var status string
if err := row.Scan(
&exchange.ID, &exchange.RevisionID, &exchange.RemoteExchangeID, &exchange.ExchangeTokenRef, &status, &exchange.CleanupStatus,
&exchange.RemoteVersion, &exchange.ExpiresAt, &exchange.Version, &exchange.LastErrorCategory,
&exchange.AuthCenterAuditID, &exchange.LastTraceID, &exchange.CancelledAt, &exchange.CleanupCompletedAt,
&exchange.CreatedAt, &exchange.UpdatedAt,
); err != nil {
return identity.PairingExchange{}, err
}
exchange.Status = identity.PairingStatus(status)
return exchange, nil
}

View File

@ -0,0 +1,711 @@
package store
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestIdentityPairingCancellationKeepsOlderInactivePairingBlockedUntilEveryCleanupCompletes(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx := context.Background()
type pairingFixture struct {
revisionID string
pairingID string
machineRef string
sessionRef string
createdAt time.Time
displayName string
}
now := time.Now().UTC()
fixtures := []pairingFixture{
{
revisionID: uuid.NewString(), pairingID: uuid.NewString(),
machineRef: "identity-machine-older", sessionRef: "identity-session-older",
createdAt: now.Add(-time.Minute), displayName: "older",
},
{
revisionID: uuid.NewString(), pairingID: uuid.NewString(),
machineRef: "identity-machine-latest", sessionRef: "identity-session-latest",
createdAt: now, displayName: "latest",
},
}
for _, fixture := range fixtures {
if _, err := db.pool.Exec(ctx, `
INSERT INTO gateway_identity_configuration_revisions (
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url,
machine_credential_ref,session_encryption_key_ref,created_at,updated_at
) VALUES ($1::uuid,'draft','https://auth.test.example','default','https://gateway.test.example',
'https://gateway-web.test.example',$2,$3,$4,$4)`,
fixture.revisionID, fixture.machineRef, fixture.sessionRef, fixture.createdAt); err != nil {
t.Fatalf("seed %s inactive revision: %v", fixture.displayName, err)
}
if _, err := db.CreateIdentityPairingExchange(ctx, identity.PairingExchange{
ID: fixture.pairingID, RevisionID: fixture.revisionID, RemoteExchangeID: uuid.NewString(),
ExchangeTokenRef: "identity-exchange-" + fixture.pairingID,
Status: identity.PairingCredentialsSaved, CleanupStatus: identity.PairingCleanupNone,
RemoteVersion: 4, ExpiresAt: now.Add(time.Hour), LastTraceID: "trace-start-" + fixture.displayName,
}); err != nil {
t.Fatalf("seed %s pairing: %v", fixture.displayName, err)
}
}
orphanRevisionID := uuid.NewString()
if _, err := db.pool.Exec(ctx, `
INSERT INTO gateway_identity_configuration_revisions (
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url,created_at,updated_at
) VALUES ($1::uuid,'draft','https://auth.test.example','default','https://orphan-api.test.example',
'https://orphan-web.test.example',$2,$2)`, orphanRevisionID, now.Add(time.Minute)); err != nil {
t.Fatalf("seed orphan revision: %v", err)
}
canonical, err := db.LatestInactiveIdentityConfigurationRevision(ctx)
if err != nil || canonical.ID != fixtures[1].revisionID {
t.Fatalf("canonical inactive revision=%#v error=%v, want latest exchange-backed revision", canonical, err)
}
blocked, err := db.IdentityPairingStartBlocked(ctx)
if err != nil || !blocked {
t.Fatalf("two inactive pairings blocked=%t error=%v, want blocked", blocked, err)
}
latest := fixtures[1]
latestCancelled, err := db.CancelIdentityPairingExchange(ctx, latest.pairingID, 1, "trace-cancel-latest", "audit-cancel-latest")
if err != nil {
t.Fatalf("cancel latest pairing: %v", err)
}
latestReplay, err := db.CancelIdentityPairingExchange(ctx, latest.pairingID, 1, "trace-replayed", "audit-replayed")
if err != nil {
t.Fatalf("replay latest cancellation with original version: %v", err)
}
if latestReplay.Status != identity.PairingCancelled || latestReplay.CleanupStatus != identity.PairingCleanupPending ||
latestReplay.Version != latestCancelled.Version || latestCancelled.CancelledAt == nil || latestReplay.CancelledAt == nil ||
!latestReplay.CancelledAt.Equal(*latestCancelled.CancelledAt) {
t.Fatalf("cancellation replay changed persisted intent: first=%#v replay=%#v", latestCancelled, latestReplay)
}
latestCleaned, err := db.CompleteIdentityPairingCleanup(ctx, latest.pairingID, latestCancelled.Version)
if err != nil {
t.Fatalf("complete latest cleanup: %v", err)
}
if latestCleaned.CleanupStatus != identity.PairingCleanupCompleted || latestCleaned.CleanupCompletedAt == nil {
t.Fatalf("latest cleanup state=%#v", latestCleaned)
}
assertIdentityRevisionSecretRefs(t, ctx, db, latest.revisionID, "", "")
assertIdentityRevisionSecretRefs(t, ctx, db, fixtures[0].revisionID, fixtures[0].machineRef, fixtures[0].sessionRef)
blocked, err = db.IdentityPairingStartBlocked(ctx)
if err != nil || !blocked {
t.Fatalf("older pairing was ignored after latest cleanup: blocked=%t error=%v", blocked, err)
}
canonical, err = db.LatestInactiveIdentityConfigurationRevision(ctx)
if err != nil || canonical.ID != fixtures[0].revisionID {
t.Fatalf("canonical inactive revision after latest cleanup=%#v error=%v, want older blocker", canonical, err)
}
older := fixtures[0]
olderCancelled, err := db.CancelIdentityPairingExchange(ctx, older.pairingID, 1, "trace-cancel-older", "audit-cancel-older")
if err != nil {
t.Fatalf("cancel older pairing: %v", err)
}
olderReplay, err := db.CancelIdentityPairingExchange(ctx, older.pairingID, 1, "trace-replayed", "audit-replayed")
if err != nil || olderReplay.Version != olderCancelled.Version {
t.Fatalf("replay older cancellation: replay=%#v error=%v", olderReplay, err)
}
if _, err := db.CompleteIdentityPairingCleanup(ctx, older.pairingID, olderCancelled.Version); err != nil {
t.Fatalf("complete older cleanup: %v", err)
}
assertIdentityRevisionSecretRefs(t, ctx, db, older.revisionID, "", "")
blocked, err = db.IdentityPairingStartBlocked(ctx)
if err != nil || blocked {
t.Fatalf("all inactive pairing cleanup blocked=%t error=%v, want unblocked", blocked, err)
}
canonical, err = db.LatestInactiveIdentityConfigurationRevision(ctx)
if err != nil || canonical.ID != fixtures[1].revisionID || canonical.ID == orphanRevisionID {
t.Fatalf("canonical cleaned revision=%#v error=%v, orphan draft must stay hidden", canonical, err)
}
}
func TestIdentityPairingStartReservationSerializesAndRejectsStaleCommit(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx := context.Background()
firstAttempt := uuid.NewString()
secondAttempt := uuid.NewString()
if err := db.ReserveIdentityPairingStart(ctx, firstAttempt, time.Now().Add(time.Minute)); err != nil {
t.Fatalf("reserve first pairing start: %v", err)
}
if err := db.ReserveIdentityPairingStart(ctx, secondAttempt, time.Now().Add(time.Minute)); !errors.Is(err, identity.ErrPairingInProgress) {
t.Fatalf("second pairing reservation error=%v, want in-progress conflict", err)
}
if _, err := db.pool.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation SET expires_at=now()-interval '1 second'
WHERE attempt_id=$1::uuid`, firstAttempt); err != nil {
t.Fatalf("expire first pairing reservation: %v", err)
}
if err := db.ReserveIdentityPairingStart(ctx, secondAttempt, time.Now().Add(time.Minute)); err != nil {
t.Fatalf("take over expired pairing reservation: %v", err)
}
staleRevision := identity.Revision{
ID: uuid.NewString(), State: identity.RevisionDraft, SchemaVersion: 1, AuthCenterURL: "https://auth.test.example",
RolePrefix: "gateway.", LocalTenantKey: "default", PublicBaseURL: "https://gateway.test.example",
WebBaseURL: "https://gateway-web.test.example", Scopes: []string{}, Capabilities: []string{},
SessionIdleSeconds: 1800, SessionAbsoluteSeconds: 28800, SessionRefreshSeconds: 60,
}
if _, err := db.CommitIdentityPairingStart(ctx, staleRevision, identity.PairingExchange{
ID: firstAttempt, RevisionID: staleRevision.ID, RemoteExchangeID: uuid.NewString(),
ExchangeTokenRef: "identity-exchange-" + firstAttempt, Status: identity.PairingMetadataPending,
CleanupStatus: identity.PairingCleanupNone, RemoteVersion: 1, ExpiresAt: time.Now().Add(time.Minute),
}); !errors.Is(err, identity.ErrPairingInProgress) {
t.Fatalf("stale start commit error=%v, want in-progress conflict", err)
}
validRevision := staleRevision
validRevision.ID = uuid.NewString()
tokenReference := "identity-exchange-" + secondAttempt
if err := db.QueueIdentitySecretCleanup(ctx, tokenReference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatalf("stage replacement exchange token reference: %v", err)
}
committed, err := db.CommitIdentityPairingStart(ctx, validRevision, identity.PairingExchange{
ID: secondAttempt, RevisionID: validRevision.ID, RemoteExchangeID: uuid.NewString(),
ExchangeTokenRef: tokenReference, Status: identity.PairingMetadataPending,
CleanupStatus: identity.PairingCleanupNone, RemoteVersion: 1, ExpiresAt: time.Now().Add(time.Minute),
})
if err != nil {
t.Fatalf("commit replacement pairing: %v", err)
}
if err := db.ReleaseIdentityPairingStart(ctx, secondAttempt); err != nil {
t.Fatalf("release paired reservation: %v", err)
}
var reservationState string
if err := db.pool.QueryRow(ctx, `SELECT state FROM gateway_identity_pairing_start_reservation
WHERE attempt_id=$1::uuid`, secondAttempt).Scan(&reservationState); err != nil || reservationState != "paired" {
t.Fatalf("paired reservation state=%q error=%v", reservationState, err)
}
blocked, err := db.IdentityPairingStartBlocked(ctx)
if err != nil || !blocked {
t.Fatalf("committed pairing did not retain singleton reservation: blocked=%t error=%v", blocked, err)
}
cancelled, err := db.CancelIdentityPairingExchange(ctx, committed.ID, committed.Version, "trace-cancel", "audit-cancel")
if err != nil {
t.Fatalf("cancel replacement pairing: %v", err)
}
if _, err := db.CompleteIdentityPairingCleanup(ctx, committed.ID, cancelled.Version); err != nil {
t.Fatalf("complete replacement pairing cleanup: %v", err)
}
blocked, err = db.IdentityPairingStartBlocked(ctx)
if err != nil || blocked {
t.Fatalf("completed cleanup retained singleton reservation: blocked=%t error=%v", blocked, err)
}
}
func TestIdentityPairingStartRejectsAnyActiveConfigurationBeforeReservation(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx := context.Background()
if _, err := db.pool.Exec(ctx, `INSERT INTO gateway_identity_configuration_revisions (
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url
) VALUES ($1::uuid,'active','https://auth.test.example','default','https://gateway.test.example',
'https://gateway-web.test.example')`, uuid.NewString()); err != nil {
t.Fatalf("seed active identity revision: %v", err)
}
err := db.ReserveIdentityPairingStart(ctx, uuid.NewString(), time.Now().Add(time.Minute))
if !errors.Is(err, identity.ErrActiveConfigurationHandoffRequired) {
t.Fatalf("active identity configuration reservation error=%v", err)
}
var reservations int
if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_identity_pairing_start_reservation`).Scan(&reservations); err != nil {
t.Fatal(err)
}
if reservations != 0 {
t.Fatalf("active credential guard left %d start reservations", reservations)
}
}
func TestConcurrentIdentityPairingStartReservationsHaveSingleWinner(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
attempts := []string{uuid.NewString(), uuid.NewString()}
ready := make(chan struct{}, len(attempts))
start := make(chan struct{})
type reservationResult struct {
attemptID string
err error
}
results := make(chan reservationResult, len(attempts))
for _, attemptID := range attempts {
go func() {
ready <- struct{}{}
<-start
results <- reservationResult{
attemptID: attemptID,
err: db.ReserveIdentityPairingStart(ctx, attemptID, time.Now().Add(5*time.Minute)),
}
}()
}
for range attempts {
<-ready
}
close(start)
var winner string
conflicts := 0
for range attempts {
result := <-results
switch {
case result.err == nil:
if winner != "" {
t.Fatalf("multiple concurrent reservations succeeded: %s and %s", winner, result.attemptID)
}
winner = result.attemptID
case errors.Is(result.err, identity.ErrPairingInProgress):
conflicts++
default:
t.Fatalf("reserve concurrent pairing start %s: %v", result.attemptID, result.err)
}
}
if winner == "" || conflicts != 1 {
t.Fatalf("concurrent reservation winner=%q conflicts=%d, want one winner and one conflict", winner, conflicts)
}
var persistedAttemptID, state string
var reservations int
if err := db.pool.QueryRow(ctx, `SELECT count(*),COALESCE(max(attempt_id::text),''),COALESCE(max(state),'')
FROM gateway_identity_pairing_start_reservation`).Scan(&reservations, &persistedAttemptID, &state); err != nil {
t.Fatalf("read concurrent reservation result: %v", err)
}
if reservations != 1 || persistedAttemptID != winner || state != "starting" {
t.Fatalf("persisted reservations=%d attempt=%q state=%q, want winner %q in starting state",
reservations, persistedAttemptID, state, winner)
}
}
func TestConcurrentIdentityActivationAndPairingReservationRemainMutuallyExclusive(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
seedIdentityActivationPrerequisites(t, ctx, db, "default")
revisionID := seedIdentityLifecycleRevision(t, ctx, db, identity.RevisionValidated, "default")
attemptID := uuid.NewString()
gate, err := db.pool.Begin(ctx)
if err != nil {
t.Fatalf("begin identity lifecycle gate: %v", err)
}
defer gate.Rollback(context.Background())
if err := lockIdentityConfigurationLifecycle(ctx, gate); err != nil {
t.Fatalf("lock identity lifecycle gate: %v", err)
}
reserveResult := make(chan error, 1)
go func() {
reserveResult <- db.ReserveIdentityPairingStart(ctx, attemptID, time.Now().Add(5*time.Minute))
}()
waitForIdentityLifecycleLockWaiters(t, ctx, db, 1)
type activationResult struct {
revision identity.Revision
err error
}
activateResult := make(chan activationResult, 1)
go func() {
revision, _, activateErr := db.ActivateIdentityRevision(ctx, revisionID, 1, "trace-activate", "audit-activate")
activateResult <- activationResult{revision: revision, err: activateErr}
}()
waitForIdentityLifecycleLockWaiters(t, ctx, db, 2)
if err := gate.Commit(ctx); err != nil {
t.Fatalf("release identity lifecycle gate: %v", err)
}
reserveErr := <-reserveResult
activated := <-activateResult
if reserveErr != nil {
t.Fatalf("first queued pairing reservation failed: %v", reserveErr)
}
var activeRevisions, startingReservations int
if err := db.pool.QueryRow(ctx, `SELECT
(SELECT count(*) FROM gateway_identity_configuration_revisions WHERE state='active'),
(SELECT count(*) FROM gateway_identity_pairing_start_reservation WHERE state='starting')`).Scan(
&activeRevisions, &startingReservations,
); err != nil {
t.Fatalf("read activation and reservation invariant: %v", err)
}
if !errors.Is(activated.err, identity.ErrPairingInProgress) || activeRevisions != 0 || startingReservations != 1 {
t.Fatalf("racing activation error=%v revision_state=%q left active=%d starting reservations=%d; "+
"want pairing-in-progress, active=0, reservation=1",
activated.err, activated.revision.State, activeRevisions, startingReservations)
}
}
func TestConcurrentIdentityPairingCommitRejectsActiveRevisionCommittedAheadOfLifecycleLock(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
attemptID := uuid.NewString()
if err := db.ReserveIdentityPairingStart(ctx, attemptID, time.Now().Add(5*time.Minute)); err != nil {
t.Fatalf("reserve pairing start before commit race: %v", err)
}
tokenReference := "identity-exchange-" + attemptID
if err := db.QueueIdentitySecretCleanup(ctx, tokenReference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatalf("stage exchange token reference before commit race: %v", err)
}
draft := identity.Revision{
ID: uuid.NewString(), State: identity.RevisionDraft, SchemaVersion: 1, AuthCenterURL: "https://auth.test.example",
RolePrefix: "gateway.", LocalTenantKey: "default", PublicBaseURL: "https://gateway.test.example",
WebBaseURL: "https://gateway-web.test.example", Scopes: []string{}, Capabilities: []string{},
SessionIdleSeconds: 1800, SessionAbsoluteSeconds: 28800, SessionRefreshSeconds: 60,
}
exchange := identity.PairingExchange{
ID: attemptID, RevisionID: draft.ID, RemoteExchangeID: uuid.NewString(), ExchangeTokenRef: tokenReference,
Status: identity.PairingMetadataPending, CleanupStatus: identity.PairingCleanupNone,
RemoteVersion: 1, ExpiresAt: time.Now().Add(5 * time.Minute),
}
gate, err := db.pool.Begin(ctx)
if err != nil {
t.Fatalf("begin identity lifecycle gate: %v", err)
}
defer gate.Rollback(context.Background())
if err := lockIdentityConfigurationLifecycle(ctx, gate); err != nil {
t.Fatalf("lock identity lifecycle gate: %v", err)
}
commitResult := make(chan error, 1)
go func() {
_, commitErr := db.CommitIdentityPairingStart(ctx, draft, exchange)
commitResult <- commitErr
}()
waitForIdentityLifecycleLockWaiters(t, ctx, db, 1)
activeID := uuid.NewString()
if _, err := gate.Exec(ctx, `INSERT INTO gateway_identity_configuration_revisions (
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url
) VALUES ($1::uuid,'active','https://auth.test.example','default','https://active-gateway.test.example',
'https://active-gateway-web.test.example')`, activeID); err != nil {
t.Fatalf("commit active revision ahead of pairing commit: %v", err)
}
if err := gate.Commit(ctx); err != nil {
t.Fatalf("release identity lifecycle gate with active revision: %v", err)
}
commitErr := <-commitResult
if !errors.Is(commitErr, identity.ErrActiveConfigurationHandoffRequired) {
t.Fatalf("pairing commit after active revision error=%v, want active handoff required", commitErr)
}
var activeRevisions, draftRevisions, exchanges, startingReservations int
if err := db.pool.QueryRow(ctx, `SELECT
(SELECT count(*) FROM gateway_identity_configuration_revisions WHERE state='active'),
(SELECT count(*) FROM gateway_identity_configuration_revisions WHERE id=$1::uuid),
(SELECT count(*) FROM gateway_identity_onboarding_exchanges WHERE id=$2::uuid),
(SELECT count(*) FROM gateway_identity_pairing_start_reservation WHERE attempt_id=$2::uuid AND state='starting')`,
draft.ID, attemptID).Scan(&activeRevisions, &draftRevisions, &exchanges, &startingReservations); err != nil {
t.Fatalf("read pairing commit lifecycle invariant: %v", err)
}
if activeRevisions != 1 || draftRevisions != 0 || exchanges != 0 || startingReservations != 1 {
t.Fatalf("pairing commit race left active=%d draft=%d exchanges=%d starting reservations=%d; "+
"want active=1 draft=0 exchanges=0 reservation=1",
activeRevisions, draftRevisions, exchanges, startingReservations)
}
}
func TestConcurrentIdentityDisableAndActivationPreserveSingleActiveRevision(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
seedIdentityActivationPrerequisites(t, ctx, db, "default")
previousID := seedIdentityLifecycleRevision(t, ctx, db, identity.RevisionActive, "default")
nextID := seedIdentityLifecycleRevision(t, ctx, db, identity.RevisionValidated, "default")
gate, err := db.pool.Begin(ctx)
if err != nil {
t.Fatalf("begin identity lifecycle gate: %v", err)
}
defer gate.Rollback(context.Background())
if err := lockIdentityConfigurationLifecycle(ctx, gate); err != nil {
t.Fatalf("lock identity lifecycle gate: %v", err)
}
disableResult := make(chan error, 1)
go func() {
_, disableErr := db.DisableActiveIdentityRevision(ctx, 1, "trace-disable", "audit-disable")
disableResult <- disableErr
}()
waitForIdentityLifecycleLockWaiters(t, ctx, db, 1)
activateResult := make(chan error, 1)
go func() {
_, _, activateErr := db.ActivateIdentityRevision(ctx, nextID, 1, "trace-activate", "audit-activate")
activateResult <- activateErr
}()
waitForIdentityLifecycleLockWaiters(t, ctx, db, 2)
if err := gate.Commit(ctx); err != nil {
t.Fatalf("release identity lifecycle gate: %v", err)
}
if err := <-disableResult; err != nil {
t.Fatalf("disable first queued active revision: %v", err)
}
activateErr := <-activateResult
if activateErr != nil {
t.Fatalf("activate revision after first queued disable: %v", activateErr)
}
var activeIDs []string
rows, err := db.pool.Query(ctx, `SELECT id::text FROM gateway_identity_configuration_revisions
WHERE state='active' ORDER BY id`)
if err != nil {
t.Fatalf("read active revisions after disable/activate race: %v", err)
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
t.Fatalf("scan active revision after disable/activate race: %v", err)
}
activeIDs = append(activeIDs, id)
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate active revisions after disable/activate race: %v", err)
}
if len(activeIDs) != 1 || activeIDs[0] != nextID {
t.Fatalf("disable/activate race left active revisions=%v; previous=%q next=%q",
activeIDs, previousID, nextID)
}
}
func TestCompletedIdentityPairingRestoreUsesCanonicalVisibleRevision(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx := context.Background()
now := time.Now().UTC()
var latestRevisionID string
for index, createdAt := range []time.Time{now.Add(-time.Minute), now} {
revisionID, pairingID := uuid.NewString(), uuid.NewString()
if _, err := db.pool.Exec(ctx, `
INSERT INTO gateway_identity_configuration_revisions (
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url,created_at,updated_at
) VALUES ($1::uuid,'validated','https://auth.test.example','default','https://gateway.test.example',
'https://gateway-web.test.example',$2,$2)`, revisionID, createdAt); err != nil {
t.Fatalf("seed completed revision %d: %v", index, err)
}
if _, err := db.CreateIdentityPairingExchange(ctx, identity.PairingExchange{
ID: pairingID, RevisionID: revisionID, RemoteExchangeID: uuid.NewString(),
ExchangeTokenRef: "identity-exchange-" + pairingID, Status: identity.PairingCompleted,
CleanupStatus: identity.PairingCleanupNone, RemoteVersion: 4, ExpiresAt: now.Add(time.Hour),
}); err != nil {
t.Fatalf("seed completed pairing %d: %v", index, err)
}
latestRevisionID = revisionID
}
completed, err := db.CompletedIdentityPairingsAwaitingActivation(ctx)
if err != nil {
t.Fatal(err)
}
if len(completed) != 1 || completed[0].RevisionID != latestRevisionID {
t.Fatalf("restored completed pairings=%#v, want only canonical latest revision %s", completed, latestRevisionID)
}
visible, err := db.LatestInactiveIdentityConfigurationRevision(ctx)
if err != nil || visible.ID != latestRevisionID {
t.Fatalf("visible inactive revision=%#v error=%v, want same canonical revision", visible, err)
}
}
func TestIdentityPairingPersistsBindingErrorCategoryAllowedByLatestMigration(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx := context.Background()
revisionID := uuid.NewString()
if _, err := db.pool.Exec(ctx, `INSERT INTO gateway_identity_configuration_revisions (
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url
) VALUES ($1::uuid,'draft','https://auth.test.example','default','https://gateway.test.example',
'https://gateway-web.test.example')`, revisionID); err != nil {
t.Fatalf("seed revision for binding error category: %v", err)
}
pairingID := uuid.NewString()
created, err := db.CreateIdentityPairingExchange(ctx, identity.PairingExchange{
ID: pairingID, RevisionID: revisionID, RemoteExchangeID: uuid.NewString(),
ExchangeTokenRef: "identity-exchange-" + pairingID, Status: identity.PairingCredentialsSaved,
CleanupStatus: identity.PairingCleanupNone, RemoteVersion: 4, ExpiresAt: time.Now().Add(time.Hour),
})
if err != nil {
t.Fatalf("create pairing for binding error category: %v", err)
}
const category = "security_event_connection_binding_mismatch"
updated, err := db.RecordIdentityPairingRetryFailure(ctx, created.ID, created.Status, category)
if err != nil {
t.Fatalf("persist binding error category: %v", err)
}
if updated.LastErrorCategory != category {
t.Fatalf("binding error category=%q, want %q", updated.LastErrorCategory, category)
}
var persisted string
if err := db.pool.QueryRow(ctx, `SELECT last_error_category FROM gateway_identity_onboarding_exchanges
WHERE id=$1::uuid`, created.ID).Scan(&persisted); err != nil {
t.Fatalf("read persisted binding error category: %v", err)
}
if persisted != category {
t.Fatalf("persisted binding error category=%q, want %q", persisted, category)
}
}
func assertIdentityRevisionSecretRefs(t *testing.T, ctx context.Context, db *Store, revisionID, wantMachine, wantSession string) {
t.Helper()
revision, err := db.IdentityConfigurationRevision(ctx, revisionID)
if err != nil {
t.Fatalf("read revision %s: %v", revisionID, err)
}
if revision.MachineCredentialRef != wantMachine || revision.SessionEncryptionKeyRef != wantSession {
t.Fatalf("revision %s secret refs machine=%q session=%q, want machine=%q session=%q",
revisionID, revision.MachineCredentialRef, revision.SessionEncryptionKeyRef, wantMachine, wantSession)
}
}
func seedIdentityActivationPrerequisites(t *testing.T, ctx context.Context, db *Store, tenantKey string) {
t.Helper()
if _, err := db.pool.Exec(ctx, `INSERT INTO gateway_tenants(tenant_key,name,status)
VALUES($1,$2,'active')`, tenantKey, "Identity lifecycle test tenant"); err != nil {
t.Fatalf("seed identity lifecycle tenant: %v", err)
}
if _, err := db.pool.Exec(ctx, `INSERT INTO gateway_users(user_key,username,password_hash,roles,source,status)
VALUES($1,$2,$3,'["manager"]'::jsonb,'gateway','active')`,
"identity-lifecycle-manager", "identity-lifecycle-manager", "test-only-password-hash"); err != nil {
t.Fatalf("seed identity lifecycle break-glass manager: %v", err)
}
}
func seedIdentityLifecycleRevision(
t *testing.T,
ctx context.Context,
db *Store,
state identity.RevisionState,
tenantKey string,
) string {
t.Helper()
revisionID := uuid.NewString()
if _, err := db.pool.Exec(ctx, `INSERT INTO gateway_identity_configuration_revisions (
id,state,auth_center_url,issuer,tenant_id,application_id,audience,browser_client_id,
local_tenant_key,public_base_url,web_base_url,validated_at,activated_at
) VALUES (
$1::uuid,$2,'https://auth.test.example','https://issuer.test.example',$3,$4,$5,$6,
$7,'https://gateway.test.example','https://gateway-web.test.example',
CASE WHEN $2 IN ('validated','active') THEN now() ELSE NULL END,
CASE WHEN $2='active' THEN now() ELSE NULL END
)`, revisionID, state, uuid.NewString(), uuid.NewString(), "urn:easyai:resource:"+revisionID,
"browser-"+revisionID, tenantKey); err != nil {
t.Fatalf("seed %s identity lifecycle revision: %v", state, err)
}
return revisionID
}
func waitForIdentityLifecycleLockWaiters(t *testing.T, ctx context.Context, db *Store, want int) {
t.Helper()
classID := int64(uint64(identityConfigurationLifecycleLockID) >> 32)
objectID := int64(uint64(identityConfigurationLifecycleLockID) & 0xffffffff)
deadline := time.Now().Add(5 * time.Second)
for {
var waiters int
if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM pg_locks
WHERE locktype='advisory' AND database=(SELECT oid FROM pg_database WHERE datname=current_database())
AND classid::bigint=$1 AND objid::bigint=$2 AND objsubid=1 AND NOT granted`, classID, objectID).Scan(&waiters); err != nil {
t.Fatalf("read identity lifecycle lock waiters: %v", err)
}
if waiters >= want {
return
}
if time.Now().After(deadline) {
t.Fatalf("identity lifecycle lock waiters=%d, want at least %d", waiters, want)
}
time.Sleep(10 * time.Millisecond)
}
}
func newIdentityPairingPostgresTestStore(t *testing.T) *Store {
t.Helper()
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run identity pairing PostgreSQL integration tests")
}
ctx := context.Background()
admin, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatalf("connect identity pairing test database: %v", err)
}
var databaseName string
if err := admin.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil {
admin.Close()
t.Fatalf("read identity pairing test database name: %v", err)
}
if !strings.Contains(strings.ToLower(databaseName), "test") {
admin.Close()
t.Fatalf("refusing to use non-test database %q", databaseName)
}
schemaName := "gateway_identity_pairing_" + strings.ReplaceAll(uuid.NewString(), "-", "")
schemaIdentifier := pgx.Identifier{schemaName}.Sanitize()
if _, err := admin.Exec(ctx, `CREATE SCHEMA `+schemaIdentifier); err != nil {
admin.Close()
t.Fatalf("create identity pairing test schema: %v", err)
}
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
_, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`)
admin.Close()
t.Fatalf("parse identity pairing test database URL: %v", err)
}
config.ConnConfig.RuntimeParams["search_path"] = schemaName
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
_, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`)
admin.Close()
t.Fatalf("connect identity pairing test schema: %v", err)
}
t.Cleanup(func() {
pool.Close()
if _, err := admin.Exec(context.Background(), `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`); err != nil {
t.Errorf("drop identity pairing test schema: %v", err)
}
admin.Close()
})
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate identity pairing integration test")
}
migrationDirectory := filepath.Join(filepath.Dir(filename), "..", "..", "migrations")
for _, migrationName := range []string{
"0001_init.sql",
"0061_oidc_server_sessions.sql",
"0065_identity_configuration_revisions.sql",
"0066_identity_onboarding_exchanges.sql",
"0067_identity_secret_cleanup_queue.sql",
"0068_identity_pairing_start_reservation.sql",
} {
migration, err := os.ReadFile(filepath.Join(migrationDirectory, migrationName))
if err != nil {
t.Fatalf("read %s: %v", migrationName, err)
}
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin %s: %v", migrationName, err)
}
if _, err := tx.Exec(ctx, string(migration)); err != nil {
_ = tx.Rollback(ctx)
t.Fatalf("execute %s: %v", migrationName, err)
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("commit %s: %v", migrationName, err)
}
}
return &Store{pool: pool}
}

View File

@ -0,0 +1,174 @@
package store
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
const identitySecretCleanupMigration = "0067_identity_secret_cleanup_queue.sql"
func TestIdentitySecretCleanupMigrationDefinesClaimLifecycle(t *testing.T) {
payload, err := os.ReadFile(identitySecretCleanupMigrationPath(t, identitySecretCleanupMigration))
if err != nil {
t.Fatal(err)
}
content := strings.ToLower(string(payload))
for _, required := range []string{
"create table if not exists gateway_identity_secret_cleanup_queue",
"secret_ref text primary key",
"status text not null default 'pending'",
"claim_token uuid",
"lease_expires_at timestamptz",
"gateway_identity_secret_cleanup_claim_check",
"idx_gateway_identity_secret_cleanup_due",
} {
if !strings.Contains(content, required) {
t.Fatalf("identity Secret cleanup migration is missing %q", required)
}
}
for _, forbidden := range []string{"secret_value", "client_secret", "machine_secret", "token text", "drop ", "set not null"} {
if strings.Contains(content, forbidden) {
t.Fatalf("identity Secret cleanup migration contains forbidden content %q", forbidden)
}
}
}
func TestIdentitySecretCleanupMigrationSupportsClaimLifecycle(t *testing.T) {
db := newIdentitySecretCleanupMigrationPostgresStore(t)
ctx := context.Background()
applyIdentitySecretCleanupMigration(t, ctx, db.pool, identitySecretCleanupMigration)
reference := "identity-cleanup-" + uuid.NewString()
if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(-time.Minute)); err != nil {
t.Fatalf("queue identity Secret cleanup: %v", err)
}
claims, err := db.ClaimIdentitySecretCleanups(ctx, 1, time.Minute)
if err != nil {
t.Fatalf("claim identity Secret cleanup: %v", err)
}
if len(claims) != 1 || claims[0].Reference != reference || claims[0].ClaimToken == "" {
t.Fatalf("claim identity Secret cleanup returned unexpected metadata: %#v", claims)
}
completed, err := db.CompleteIdentitySecretCleanup(ctx, reference, claims[0].ClaimToken)
if err != nil || !completed {
t.Fatalf("complete identity Secret cleanup: completed=%t err=%v", completed, err)
}
invalidReference := "identity-cleanup-invalid-" + uuid.NewString()
_, err = db.pool.Exec(ctx, `
INSERT INTO gateway_identity_secret_cleanup_queue(
secret_ref,not_before,status,claim_token,lease_expires_at
) VALUES($1,now(),'claimed',NULL,NULL)`, invalidReference)
requireIdentitySecretCleanupMigrationCheckViolation(t, err)
var indexDefinition string
if err := db.pool.QueryRow(ctx, `
SELECT pg_get_indexdef(indexrelid)
FROM pg_index
WHERE indexrelid='idx_gateway_identity_secret_cleanup_due'::regclass`).Scan(&indexDefinition); err != nil {
t.Fatalf("read cleanup due index: %v", err)
}
if !strings.Contains(strings.ToLower(indexDefinition), "(status, not_before, lease_expires_at, updated_at)") {
t.Fatalf("cleanup due index does not cover the claim lifecycle: %q", indexDefinition)
}
}
func newIdentitySecretCleanupMigrationPostgresStore(t *testing.T) *Store {
t.Helper()
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run identity Secret cleanup migration integration tests")
}
ctx := context.Background()
admin, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatalf("connect identity migration test database: %v", err)
}
var databaseName string
if err := admin.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil {
admin.Close()
t.Fatalf("read identity migration test database name: %v", err)
}
if !strings.Contains(strings.ToLower(databaseName), "test") {
admin.Close()
t.Fatalf("refusing to use non-test database %q", databaseName)
}
schemaName := "gateway_identity_cleanup_migration_" + strings.ReplaceAll(uuid.NewString(), "-", "")
schemaIdentifier := pgx.Identifier{schemaName}.Sanitize()
if _, err := admin.Exec(ctx, `CREATE SCHEMA `+schemaIdentifier); err != nil {
admin.Close()
t.Fatalf("create identity migration test schema: %v", err)
}
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
_, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`)
admin.Close()
t.Fatalf("parse identity migration test database URL: %v", err)
}
config.ConnConfig.RuntimeParams["search_path"] = schemaName
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
_, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`)
admin.Close()
t.Fatalf("connect identity migration test schema: %v", err)
}
t.Cleanup(func() {
pool.Close()
if _, err := admin.Exec(context.Background(), `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`); err != nil {
t.Errorf("drop identity migration test schema: %v", err)
}
admin.Close()
})
return &Store{pool: pool}
}
func applyIdentitySecretCleanupMigration(t *testing.T, ctx context.Context, pool *pgxpool.Pool, name string) {
t.Helper()
payload, err := os.ReadFile(identitySecretCleanupMigrationPath(t, name))
if err != nil {
t.Fatalf("read identity Secret cleanup migration: %v", err)
}
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin identity Secret cleanup migration: %v", err)
}
if _, err := tx.Exec(ctx, string(payload)); err != nil {
_ = tx.Rollback(ctx)
t.Fatalf("execute identity Secret cleanup migration: %v", err)
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("commit identity Secret cleanup migration: %v", err)
}
}
func identitySecretCleanupMigrationPath(t *testing.T, name string) string {
t.Helper()
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate identity Secret cleanup migration test")
}
return filepath.Join(filepath.Dir(filename), "..", "..", "migrations", name)
}
func requireIdentitySecretCleanupMigrationCheckViolation(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("invalid cleanup claim lifecycle unexpectedly satisfied migration constraints")
}
var postgresError *pgconn.PgError
if !errors.As(err, &postgresError) || postgresError.Code != "23514" {
t.Fatalf("invalid cleanup claim lifecycle error=%v, want PostgreSQL check violation", err)
}
}

View File

@ -0,0 +1,477 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
var (
ErrSecurityEventConnectionNotFound = errors.New("security event connection not found")
ErrSecurityEventConnectionConflict = errors.New("security event connection conflicts with current state")
ErrIdentitySecretCleanupConflict = errors.New("identity secret cleanup conflicts with current state")
)
type SecurityEventConnection struct {
ConnectionID string `json:"connectionId"`
TransmitterIssuer string `json:"transmitterIssuer"`
EndpointURL string `json:"endpointUrl"`
Audience *string `json:"audience,omitempty"`
StreamID *string `json:"streamId,omitempty"`
CredentialRef string `json:"-"`
NextCredentialRef *string `json:"-"`
ManagementClientID string `json:"managementClientId"`
ManagementCredentialRef *string `json:"-"`
LifecycleStatus string `json:"lifecycleStatus"`
Version int64 `json:"version"`
LastErrorCategory *string `json:"lastErrorCategory,omitempty"`
IdempotencyKey string `json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastVerificationAt *time.Time `json:"lastVerificationAt,omitempty"`
HealthMode string `json:"healthMode"`
}
type CreateSecurityEventConnectionInput struct {
ConnectionID, TransmitterIssuer, EndpointURL, CredentialRef, ManagementClientID, ManagementCredentialRef, IdempotencyKey string
}
type SecurityEventConnectionIdempotency struct {
RequestHash string
Response json.RawMessage
}
type IdentitySecretCleanupClaim struct {
Reference string
ClaimToken string
LeaseExpiresAt time.Time
}
func (s *Store) QueueIdentitySecretCleanup(ctx context.Context, reference string, notBefore time.Time) error {
tag, err := s.pool.Exec(ctx, `INSERT INTO gateway_identity_secret_cleanup_queue(secret_ref,not_before)
VALUES($1,$2) ON CONFLICT(secret_ref) DO UPDATE
SET not_before=LEAST(gateway_identity_secret_cleanup_queue.not_before,EXCLUDED.not_before),updated_at=now()
WHERE gateway_identity_secret_cleanup_queue.status='pending'`, reference, notBefore)
if err != nil {
return err
}
if tag.RowsAffected() != 1 {
return ErrIdentitySecretCleanupConflict
}
return nil
}
func (s *Store) RemovePendingIdentitySecretCleanup(ctx context.Context, reference string) (bool, error) {
tag, err := s.pool.Exec(ctx, `DELETE FROM gateway_identity_secret_cleanup_queue
WHERE secret_ref=$1 AND status='pending'`, reference)
if err != nil {
return false, err
}
return tag.RowsAffected() == 1, nil
}
func (s *Store) ClaimIdentitySecretCleanups(ctx context.Context, limit int, lease time.Duration) ([]IdentitySecretCleanupClaim, error) {
if limit <= 0 || limit > 1000 {
limit = 100
}
if lease <= 0 || lease > time.Hour {
lease = 2 * time.Minute
}
leaseSeconds := int64((lease + time.Second - 1) / time.Second)
rows, err := s.pool.Query(ctx, `WITH candidates AS MATERIALIZED (
SELECT secret_ref FROM gateway_identity_secret_cleanup_queue
WHERE (status='pending' AND not_before <= now())
OR (status='claimed' AND lease_expires_at <= now())
ORDER BY COALESCE(lease_expires_at,not_before),created_at
FOR UPDATE SKIP LOCKED
LIMIT $1
)
UPDATE gateway_identity_secret_cleanup_queue queued
SET status='claimed',claim_token=gen_random_uuid(),
lease_expires_at=now()+make_interval(secs => $2::int),updated_at=now()
FROM candidates
WHERE queued.secret_ref=candidates.secret_ref
RETURNING queued.secret_ref,queued.claim_token::text,queued.lease_expires_at`, limit, leaseSeconds)
if err != nil {
return nil, err
}
defer rows.Close()
claims := make([]IdentitySecretCleanupClaim, 0)
for rows.Next() {
var claim IdentitySecretCleanupClaim
if err := rows.Scan(&claim.Reference, &claim.ClaimToken, &claim.LeaseExpiresAt); err != nil {
return nil, err
}
claims = append(claims, claim)
}
return claims, rows.Err()
}
func (s *Store) CompleteIdentitySecretCleanup(ctx context.Context, reference, claimToken string) (bool, error) {
if _, err := uuid.Parse(claimToken); err != nil {
return false, ErrIdentitySecretCleanupConflict
}
tag, err := s.pool.Exec(ctx, `DELETE FROM gateway_identity_secret_cleanup_queue
WHERE secret_ref=$1 AND status='claimed' AND claim_token=$2::uuid`, reference, claimToken)
if err != nil {
return false, err
}
return tag.RowsAffected() == 1, nil
}
func adoptPendingIdentitySecrets(ctx context.Context, tx pgx.Tx, references ...string) error {
unique := make(map[string]struct{}, len(references))
for _, reference := range references {
if reference == "" {
return ErrIdentitySecretCleanupConflict
}
unique[reference] = struct{}{}
}
values := make([]string, 0, len(unique))
for reference := range unique {
values = append(values, reference)
}
tag, err := tx.Exec(ctx, `DELETE FROM gateway_identity_secret_cleanup_queue
WHERE secret_ref=ANY($1) AND status='pending'`, values)
if err != nil {
return err
}
if tag.RowsAffected() != int64(len(values)) {
return ErrIdentitySecretCleanupConflict
}
return nil
}
func queueIdentitySecretCleanupTx(ctx context.Context, tx pgx.Tx, reference string, notBefore time.Time) error {
tag, err := tx.Exec(ctx, `INSERT INTO gateway_identity_secret_cleanup_queue(secret_ref,not_before)
VALUES($1,$2) ON CONFLICT(secret_ref) DO UPDATE
SET not_before=LEAST(gateway_identity_secret_cleanup_queue.not_before,EXCLUDED.not_before),updated_at=now()
WHERE gateway_identity_secret_cleanup_queue.status='pending'`, reference, notBefore)
if err != nil {
return err
}
if tag.RowsAffected() != 1 {
return ErrIdentitySecretCleanupConflict
}
return nil
}
func (s *Store) SecurityEventConnectionIdempotency(ctx context.Context, operation, key string) (SecurityEventConnectionIdempotency, error) {
var value SecurityEventConnectionIdempotency
err := s.pool.QueryRow(ctx, `SELECT request_hash,response FROM gateway_security_event_connection_idempotency
WHERE operation=$1 AND idempotency_key=$2`, operation, key).Scan(&value.RequestHash, &value.Response)
if errors.Is(err, pgx.ErrNoRows) {
return SecurityEventConnectionIdempotency{}, ErrSecurityEventConnectionNotFound
}
return value, err
}
func (s *Store) RecordSecurityEventConnectionIdempotency(ctx context.Context, operation, key, requestHash string, response []byte) error {
if !json.Valid(response) {
return errors.New("security event idempotency response is invalid")
}
_, err := s.pool.Exec(ctx, `INSERT INTO gateway_security_event_connection_idempotency(operation,idempotency_key,request_hash,response)
VALUES($1,$2,$3,$4::jsonb) ON CONFLICT(operation,idempotency_key) DO NOTHING`, operation, key, requestHash, response)
return err
}
func (s *Store) CreateSecurityEventConnection(ctx context.Context, input CreateSecurityEventConnectionInput) (SecurityEventConnection, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return SecurityEventConnection{}, err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
INSERT INTO gateway_security_event_connections(
connection_id,transmitter_issuer,endpoint_url,credential_ref,management_client_id,management_credential_ref,lifecycle_status,idempotency_key
) VALUES($1::uuid,$2,$3,$4,$5,$6,'connecting',$7)
`,
input.ConnectionID, input.TransmitterIssuer, input.EndpointURL, input.CredentialRef,
input.ManagementClientID, input.ManagementCredentialRef, input.IdempotencyKey,
)
if err != nil {
if isUniqueViolation(err) {
_ = tx.Rollback(ctx)
existing, getErr := s.SecurityEventConnection(ctx)
if getErr == nil && existing.IdempotencyKey == input.IdempotencyKey && existing.TransmitterIssuer == input.TransmitterIssuer {
return existing, nil
}
return SecurityEventConnection{}, ErrSecurityEventConnectionConflict
}
return SecurityEventConnection{}, err
}
if err := adoptPendingIdentitySecrets(ctx, tx, input.CredentialRef, input.ManagementCredentialRef); err != nil {
return SecurityEventConnection{}, err
}
if err := tx.Commit(ctx); err != nil {
return SecurityEventConnection{}, err
}
return s.SecurityEventConnection(ctx)
}
func (s *Store) SecurityEventConnection(ctx context.Context) (SecurityEventConnection, error) {
var value SecurityEventConnection
err := s.pool.QueryRow(ctx, `
SELECT connection.connection_id::text,connection.transmitter_issuer,connection.endpoint_url,connection.audience,
connection.stream_id::text,connection.credential_ref,connection.next_credential_ref,
COALESCE(connection.management_client_id,''),connection.management_credential_ref,connection.lifecycle_status,
connection.version,connection.last_error_category,connection.idempotency_key,connection.created_at,connection.updated_at,
state.last_verification_at,COALESCE(state.mode,'disabled')
FROM gateway_security_event_connections connection
LEFT JOIN gateway_security_event_stream_state state
ON state.issuer=connection.transmitter_issuer AND state.audience=connection.audience
WHERE connection.singleton=true`).Scan(
&value.ConnectionID, &value.TransmitterIssuer, &value.EndpointURL, &value.Audience, &value.StreamID,
&value.CredentialRef, &value.NextCredentialRef, &value.ManagementClientID, &value.ManagementCredentialRef,
&value.LifecycleStatus, &value.Version,
&value.LastErrorCategory, &value.IdempotencyKey, &value.CreatedAt, &value.UpdatedAt,
&value.LastVerificationAt, &value.HealthMode,
)
if errors.Is(err, pgx.ErrNoRows) {
return SecurityEventConnection{}, ErrSecurityEventConnectionNotFound
}
return value, err
}
func (s *Store) BindSecurityEventConnection(ctx context.Context, connectionID, audience, streamID, lifecycle string, expectedVersion int64) (SecurityEventConnection, error) {
tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections
SET audience=$2,stream_id=$3::uuid,lifecycle_status=$4,last_error_category=NULL,version=version+1,updated_at=now()
WHERE connection_id=$1::uuid AND version=$5`, connectionID, audience, streamID, lifecycle, expectedVersion)
if err != nil {
return SecurityEventConnection{}, err
}
if tag.RowsAffected() == 0 {
return SecurityEventConnection{}, ErrSecurityEventConnectionConflict
}
return s.SecurityEventConnection(ctx)
}
func (s *Store) SetSecurityEventManagementCredential(ctx context.Context, connectionID, clientID, reference string, expectedVersion int64) (SecurityEventConnection, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return SecurityEventConnection{}, err
}
defer tx.Rollback(ctx)
var oldReference sql.NullString
var lifecycle string
var version int64
if err := tx.QueryRow(ctx, `SELECT management_credential_ref,lifecycle_status,version FROM gateway_security_event_connections
WHERE connection_id=$1::uuid FOR UPDATE`, connectionID).Scan(&oldReference, &lifecycle, &version); errors.Is(err, pgx.ErrNoRows) {
return SecurityEventConnection{}, ErrSecurityEventConnectionNotFound
} else if err != nil {
return SecurityEventConnection{}, err
}
if version != expectedVersion || lifecycle == "retiring" || lifecycle == "disconnect_pending" {
return SecurityEventConnection{}, ErrSecurityEventConnectionConflict
}
tag, err := tx.Exec(ctx, `UPDATE gateway_security_event_connections
SET management_client_id=$2,management_credential_ref=$3,last_error_category=NULL,
version=version+1,updated_at=now()
WHERE connection_id=$1::uuid AND version=$4
AND lifecycle_status NOT IN ('retiring','disconnect_pending')`, connectionID, clientID, reference, expectedVersion)
if err != nil {
return SecurityEventConnection{}, err
}
if tag.RowsAffected() == 0 {
return SecurityEventConnection{}, ErrSecurityEventConnectionConflict
}
if err := adoptPendingIdentitySecrets(ctx, tx, reference); err != nil {
return SecurityEventConnection{}, err
}
if oldReference.Valid && oldReference.String != reference {
if err := queueIdentitySecretCleanupTx(ctx, tx, oldReference.String, time.Now().UTC()); err != nil {
return SecurityEventConnection{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return SecurityEventConnection{}, err
}
return s.SecurityEventConnection(ctx)
}
func (s *Store) TransitionSecurityEventConnectionLifecycle(ctx context.Context, connectionID, expectedLifecycle string, expectedVersion int64, lifecycle string, errorCategory *string) (SecurityEventConnection, error) {
tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections
SET lifecycle_status=$4,last_error_category=$5,version=version+1,updated_at=now()
WHERE connection_id=$1::uuid AND lifecycle_status=$2 AND version=$3
AND (lifecycle_status NOT IN ('retiring','disconnect_pending')
OR $4 IN ('retiring','disconnect_pending'))`,
connectionID, expectedLifecycle, expectedVersion, lifecycle, errorCategory)
if err != nil {
return SecurityEventConnection{}, err
}
if tag.RowsAffected() == 0 {
return SecurityEventConnection{}, ErrSecurityEventConnectionConflict
}
return s.SecurityEventConnection(ctx)
}
func (s *Store) SetSecurityEventNextCredential(ctx context.Context, connectionID, reference string, expectedVersion int64) (SecurityEventConnection, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return SecurityEventConnection{}, err
}
defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `UPDATE gateway_security_event_connections
SET next_credential_ref=$2,lifecycle_status='rotating',last_error_category=NULL,version=version+1,updated_at=now()
WHERE connection_id=$1::uuid AND version=$3 AND next_credential_ref IS NULL
AND lifecycle_status IN ('connecting','verifying','bootstrap','enabled','degraded','error')`, connectionID, reference, expectedVersion)
if err != nil {
return SecurityEventConnection{}, err
}
if tag.RowsAffected() == 0 {
return SecurityEventConnection{}, ErrSecurityEventConnectionConflict
}
if err := adoptPendingIdentitySecrets(ctx, tx, reference); err != nil {
return SecurityEventConnection{}, err
}
if err := tx.Commit(ctx); err != nil {
return SecurityEventConnection{}, err
}
return s.SecurityEventConnection(ctx)
}
func (s *Store) PromoteSecurityEventCredential(ctx context.Context, connectionID, nextReference string) (SecurityEventConnection, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return SecurityEventConnection{}, err
}
defer tx.Rollback(ctx)
var oldReference string
if err := tx.QueryRow(ctx, `SELECT credential_ref FROM gateway_security_event_connections
WHERE connection_id=$1::uuid AND next_credential_ref=$2 AND lifecycle_status='rotating' FOR UPDATE`, connectionID, nextReference).Scan(&oldReference); errors.Is(err, pgx.ErrNoRows) {
return SecurityEventConnection{}, ErrSecurityEventConnectionConflict
} else if err != nil {
return SecurityEventConnection{}, err
}
tag, err := tx.Exec(ctx, `UPDATE gateway_security_event_connections
SET credential_ref=$2,next_credential_ref=NULL,lifecycle_status='bootstrap',last_error_category=NULL,
version=version+1,updated_at=now()
WHERE connection_id=$1::uuid AND next_credential_ref=$2 AND lifecycle_status='rotating'`, connectionID, nextReference)
if err != nil {
return SecurityEventConnection{}, err
}
if tag.RowsAffected() == 0 {
return SecurityEventConnection{}, ErrSecurityEventConnectionConflict
}
if oldReference != nextReference {
if err := queueIdentitySecretCleanupTx(ctx, tx, oldReference, time.Now().UTC()); err != nil {
return SecurityEventConnection{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return SecurityEventConnection{}, err
}
return s.SecurityEventConnection(ctx)
}
// DiscardPreparedSecurityEventConnection atomically retires the local Secrets
// and removes only the exact, still-unbound connection generation observed by
// the caller. A concurrent Bind or credential update changes the version and
// therefore preserves both the connection and its Secrets.
func (s *Store) DiscardPreparedSecurityEventConnection(ctx context.Context, connectionID, ownerKey string, expectedVersion int64) error {
if _, err := uuid.Parse(connectionID); err != nil || ownerKey == "" || expectedVersion <= 0 {
return ErrSecurityEventConnectionConflict
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var credentialReference string
var nextReference, managementReference sql.NullString
err = tx.QueryRow(ctx, `SELECT credential_ref,next_credential_ref,management_credential_ref
FROM gateway_security_event_connections
WHERE connection_id=$1::uuid AND idempotency_key=$2 AND version=$3 AND stream_id IS NULL
FOR UPDATE`, connectionID, ownerKey, expectedVersion).Scan(
&credentialReference, &nextReference, &managementReference,
)
if errors.Is(err, pgx.ErrNoRows) {
return ErrSecurityEventConnectionConflict
}
if err != nil {
return err
}
references := map[string]struct{}{credentialReference: {}}
if nextReference.Valid {
references[nextReference.String] = struct{}{}
}
if managementReference.Valid {
references[managementReference.String] = struct{}{}
}
now := time.Now().UTC()
for reference := range references {
if err := queueIdentitySecretCleanupTx(ctx, tx, reference, now); err != nil {
return err
}
}
tag, err := tx.Exec(ctx, `DELETE FROM gateway_security_event_connections
WHERE connection_id=$1::uuid AND idempotency_key=$2 AND version=$3 AND stream_id IS NULL`,
connectionID, ownerKey, expectedVersion)
if err != nil {
return err
}
if tag.RowsAffected() != 1 {
return ErrSecurityEventConnectionConflict
}
return tx.Commit(ctx)
}
// FinalizeRetiringSecurityEventConnection atomically transfers every Secret
// reference owned by the exact retiring generation to the durable cleanup
// queue before removing that generation. The SecretStore worker can therefore
// resume after a crash without the database losing its last cleanup intent.
func (s *Store) FinalizeRetiringSecurityEventConnection(ctx context.Context, connectionID string, expectedVersion int64) error {
if _, err := uuid.Parse(connectionID); err != nil || expectedVersion <= 0 {
return ErrSecurityEventConnectionConflict
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var credentialReference string
var nextReference, managementReference sql.NullString
err = tx.QueryRow(ctx, `SELECT credential_ref,next_credential_ref,management_credential_ref
FROM gateway_security_event_connections
WHERE connection_id=$1::uuid AND lifecycle_status='retiring' AND version=$2
FOR UPDATE`, connectionID, expectedVersion).Scan(
&credentialReference, &nextReference, &managementReference,
)
if errors.Is(err, pgx.ErrNoRows) {
return ErrSecurityEventConnectionConflict
}
if err != nil {
return err
}
references := map[string]struct{}{credentialReference: {}}
if nextReference.Valid {
references[nextReference.String] = struct{}{}
}
if managementReference.Valid {
references[managementReference.String] = struct{}{}
}
now := time.Now().UTC()
for reference := range references {
if err := queueIdentitySecretCleanupTx(ctx, tx, reference, now); err != nil {
return err
}
}
tag, err := tx.Exec(ctx, `DELETE FROM gateway_security_event_connections
WHERE connection_id=$1::uuid AND lifecycle_status='retiring' AND version=$2`, connectionID, expectedVersion)
if err != nil {
return err
}
if tag.RowsAffected() != 1 {
return ErrSecurityEventConnectionConflict
}
return tx.Commit(ctx)
}

View File

@ -0,0 +1,665 @@
package store
import (
"context"
"errors"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestSecurityEventCredentialReferenceSwitchesRemainCrashRecoverable(t *testing.T) {
db := newSecurityEventSecretCleanupPostgresStore(t)
ctx := context.Background()
connectionID := uuid.NewString()
pushReference := "ssf-push-" + connectionID
managementReference := "ssf-management-" + connectionID
nextReference := "ssf-push-next-" + uuid.NewString()
newManagementReference := "ssf-management-next-" + uuid.NewString()
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`,
[]string{pushReference, managementReference, nextReference, newManagementReference})
})
if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil {
t.Fatal(err)
}
for _, reference := range []string{pushReference, managementReference} {
if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
}
connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{
ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf",
EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference,
ManagementClientID: "machine-client", ManagementCredentialRef: managementReference,
IdempotencyKey: "identity-pairing-ssf-" + uuid.NewString(),
})
if err != nil {
t.Fatal(err)
}
assertIdentitySecretQueueState(t, ctx, db, pushReference, false)
assertIdentitySecretQueueState(t, ctx, db, managementReference, false)
if err := db.QueueIdentitySecretCleanup(ctx, newManagementReference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
if _, err := db.SetSecurityEventManagementCredential(ctx, connectionID, "machine-client", newManagementReference, connection.Version); err != nil {
t.Fatal(err)
}
assertIdentitySecretQueueState(t, ctx, db, newManagementReference, false)
assertIdentitySecretQueueState(t, ctx, db, managementReference, true)
if err := db.QueueIdentitySecretCleanup(ctx, nextReference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
connection, err = db.SecurityEventConnection(ctx)
if err != nil {
t.Fatal(err)
}
connection, err = db.SetSecurityEventNextCredential(ctx, connectionID, nextReference, connection.Version)
if err != nil {
t.Fatal(err)
}
assertIdentitySecretQueueState(t, ctx, db, nextReference, false)
if _, err := db.PromoteSecurityEventCredential(ctx, connection.ConnectionID, nextReference); err != nil {
t.Fatal(err)
}
assertIdentitySecretQueueState(t, ctx, db, pushReference, true)
}
func TestDiscardPreparedSecurityEventConnectionQueuesAllSecretsAtomically(t *testing.T) {
db := newSecurityEventSecretCleanupPostgresStore(t)
ctx := context.Background()
connectionID := uuid.NewString()
ownerKey := "identity-pairing-ssf-" + uuid.NewString()
pushReference := "ssf-push-" + connectionID
managementReference := "ssf-management-" + connectionID
nextReference := "ssf-push-next-" + uuid.NewString()
references := []string{pushReference, managementReference, nextReference}
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, references)
})
if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil {
t.Fatal(err)
}
for _, reference := range []string{pushReference, managementReference} {
if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
}
connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{
ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf",
EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference,
ManagementClientID: "machine-client", ManagementCredentialRef: managementReference,
IdempotencyKey: ownerKey,
})
if err != nil {
t.Fatal(err)
}
if err := db.QueueIdentitySecretCleanup(ctx, nextReference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
connection, err = db.SetSecurityEventNextCredential(ctx, connectionID, nextReference, connection.Version)
if err != nil {
t.Fatal(err)
}
if err := db.DiscardPreparedSecurityEventConnection(ctx, connectionID, ownerKey, connection.Version); err != nil {
t.Fatal(err)
}
if _, err := db.SecurityEventConnection(ctx); !errors.Is(err, ErrSecurityEventConnectionNotFound) {
t.Fatalf("discarded prepared connection still exists: %v", err)
}
for _, reference := range references {
assertIdentitySecretQueueStatus(t, ctx, db, reference, "pending")
}
}
func TestConcurrentPreparedDiscardAndBindPreserveTheWinningGeneration(t *testing.T) {
db := newSecurityEventSecretCleanupPostgresStore(t)
ctx := context.Background()
connectionID := uuid.NewString()
ownerKey := "identity-pairing-ssf-" + uuid.NewString()
pushReference := "ssf-push-" + connectionID
managementReference := "ssf-management-" + connectionID
references := []string{pushReference, managementReference}
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, references)
})
if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil {
t.Fatal(err)
}
for _, reference := range references {
if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
}
connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{
ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf",
EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference,
ManagementClientID: "machine-client", ManagementCredentialRef: managementReference,
IdempotencyKey: ownerKey,
})
if err != nil {
t.Fatal(err)
}
type result struct {
operation string
err error
}
start := make(chan struct{})
results := make(chan result, 2)
audience := "urn:easyai:ssf:receiver:" + uuid.NewString()
streamID := uuid.NewString()
go func() {
<-start
_, err := db.BindSecurityEventConnection(ctx, connectionID, audience, streamID, "verifying", connection.Version)
results <- result{operation: "bind", err: err}
}()
go func() {
<-start
err := db.DiscardPreparedSecurityEventConnection(ctx, connectionID, ownerKey, connection.Version)
results <- result{operation: "discard", err: err}
}()
close(start)
outcomes := map[string]error{}
for range 2 {
outcome := <-results
outcomes[outcome.operation] = outcome.err
}
bindWon := outcomes["bind"] == nil
discardWon := outcomes["discard"] == nil
if bindWon == discardWon {
t.Fatalf("concurrent outcomes bind=%v discard=%v, want exactly one winner", outcomes["bind"], outcomes["discard"])
}
loser := outcomes["bind"]
if bindWon {
loser = outcomes["discard"]
}
if !errors.Is(loser, ErrSecurityEventConnectionConflict) {
t.Fatalf("concurrent loser error=%v, want connection conflict", loser)
}
if bindWon {
persisted, err := db.SecurityEventConnection(ctx)
if err != nil || persisted.StreamID == nil || *persisted.StreamID != streamID {
t.Fatalf("winning bound generation was not preserved: connection=%+v err=%v", persisted, err)
}
for _, reference := range references {
assertIdentitySecretQueueState(t, ctx, db, reference, false)
}
return
}
if _, err := db.SecurityEventConnection(ctx); !errors.Is(err, ErrSecurityEventConnectionNotFound) {
t.Fatalf("winning discard left a connection behind: %v", err)
}
for _, reference := range references {
assertIdentitySecretQueueStatus(t, ctx, db, reference, "pending")
}
}
func TestConcurrentSecurityEventNextCredentialsAdoptOnlyOneStagedSecret(t *testing.T) {
db := newSecurityEventSecretCleanupPostgresStore(t)
ctx := context.Background()
connectionID := uuid.NewString()
pushReference := "ssf-push-" + connectionID
managementReference := "ssf-management-" + connectionID
candidates := []string{
"ssf-push-next-" + uuid.NewString(),
"ssf-push-next-" + uuid.NewString(),
}
allReferences := append([]string{pushReference, managementReference}, candidates...)
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, allReferences)
})
if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil {
t.Fatal(err)
}
for _, reference := range []string{pushReference, managementReference} {
if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
}
connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{
ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf",
EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference,
ManagementClientID: "machine-client", ManagementCredentialRef: managementReference,
IdempotencyKey: "identity-pairing-ssf-" + uuid.NewString(),
})
if err != nil {
t.Fatal(err)
}
for _, reference := range candidates {
if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
}
type result struct {
reference string
err error
}
start := make(chan struct{})
results := make(chan result, len(candidates))
var workers sync.WaitGroup
for _, reference := range candidates {
workers.Add(1)
go func(reference string) {
defer workers.Done()
<-start
_, err := db.SetSecurityEventNextCredential(ctx, connectionID, reference, connection.Version)
results <- result{reference: reference, err: err}
}(reference)
}
close(start)
workers.Wait()
close(results)
var adopted, pending string
for result := range results {
switch {
case result.err == nil:
if adopted != "" {
t.Fatal("more than one concurrent next credential was adopted")
}
adopted = result.reference
case errors.Is(result.err, ErrSecurityEventConnectionConflict):
pending = result.reference
default:
t.Fatalf("concurrent next credential returned unexpected error: %v", result.err)
}
}
if adopted == "" || pending == "" {
t.Fatalf("concurrent next credential outcomes adopted=%t pending=%t", adopted != "", pending != "")
}
connection, err = db.SecurityEventConnection(ctx)
if err != nil || connection.NextCredentialRef == nil || *connection.NextCredentialRef != adopted {
t.Fatalf("connection did not retain the sole adopted next credential: %v", err)
}
assertIdentitySecretQueueState(t, ctx, db, adopted, false)
assertIdentitySecretQueueStatus(t, ctx, db, pending, "pending")
}
func TestRetiringSecurityEventConnectionCannotAdoptManagementSecret(t *testing.T) {
db := newSecurityEventSecretCleanupPostgresStore(t)
ctx := context.Background()
connectionID := uuid.NewString()
pushReference := "ssf-push-" + connectionID
managementReference := "ssf-management-" + connectionID
newManagementReference := "ssf-management-next-" + uuid.NewString()
allReferences := []string{pushReference, managementReference, newManagementReference}
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, allReferences)
})
if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil {
t.Fatal(err)
}
for _, reference := range []string{pushReference, managementReference} {
if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
}
connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{
ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf",
EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference,
ManagementClientID: "machine-client", ManagementCredentialRef: managementReference,
IdempotencyKey: "identity-pairing-ssf-" + uuid.NewString(),
})
if err != nil {
t.Fatal(err)
}
connection, err = db.TransitionSecurityEventConnectionLifecycle(
ctx, connectionID, connection.LifecycleStatus, connection.Version, "retiring", nil,
)
if err != nil {
t.Fatal(err)
}
if err := db.QueueIdentitySecretCleanup(ctx, newManagementReference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
if _, err := db.SetSecurityEventManagementCredential(ctx, connectionID, "machine-client", newManagementReference, connection.Version); !errors.Is(err, ErrSecurityEventConnectionConflict) {
t.Fatalf("retiring connection management adoption error=%v, want conflict", err)
}
connection, err = db.SecurityEventConnection(ctx)
if err != nil || connection.ManagementCredentialRef == nil || *connection.ManagementCredentialRef != managementReference {
t.Fatalf("retiring connection changed its management credential: %v", err)
}
assertIdentitySecretQueueStatus(t, ctx, db, newManagementReference, "pending")
}
func TestSecurityEventRetirementRequiresTheCurrentConnectionGeneration(t *testing.T) {
db := newSecurityEventSecretCleanupPostgresStore(t)
ctx := context.Background()
connectionID := uuid.NewString()
pushReference := "ssf-push-" + connectionID
managementReference := "ssf-management-" + connectionID
references := []string{pushReference, managementReference}
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, references)
})
if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil {
t.Fatal(err)
}
for _, reference := range references {
if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
}
connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{
ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf",
EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference,
ManagementClientID: "machine-client", ManagementCredentialRef: managementReference,
IdempotencyKey: "identity-pairing-ssf-" + uuid.NewString(),
})
if err != nil {
t.Fatal(err)
}
if _, err := db.TransitionSecurityEventConnectionLifecycle(
ctx, connectionID, "connecting", connection.Version-1, "retiring", nil,
); !errors.Is(err, ErrSecurityEventConnectionConflict) {
t.Fatalf("stale retirement transition error=%v, want conflict", err)
}
current, err := db.SecurityEventConnection(ctx)
if err != nil || current.LifecycleStatus != "connecting" || current.Version != connection.Version {
t.Fatalf("stale transition changed connection generation: lifecycle=%q version=%d err=%v",
current.LifecycleStatus, current.Version, err)
}
current, err = db.TransitionSecurityEventConnectionLifecycle(
ctx, connectionID, "connecting", connection.Version, "retiring", nil,
)
if err != nil {
t.Fatal(err)
}
if _, err := db.TransitionSecurityEventConnectionLifecycle(
ctx, connectionID, "retiring", current.Version, "error", nil,
); !errors.Is(err, ErrSecurityEventConnectionConflict) {
t.Fatalf("retiring generation revival error=%v, want conflict", err)
}
if err := db.FinalizeRetiringSecurityEventConnection(ctx, connectionID, connection.Version); !errors.Is(err, ErrSecurityEventConnectionConflict) {
t.Fatalf("stale retirement deletion error=%v, want conflict", err)
}
if persisted, err := db.SecurityEventConnection(ctx); err != nil || persisted.LifecycleStatus != "retiring" || persisted.Version != current.Version {
t.Fatalf("stale retirement deletion removed current generation: lifecycle=%q version=%d err=%v",
persisted.LifecycleStatus, persisted.Version, err)
}
if err := db.FinalizeRetiringSecurityEventConnection(ctx, connectionID, current.Version); err != nil {
t.Fatal(err)
}
if _, err := db.SecurityEventConnection(ctx); !errors.Is(err, ErrSecurityEventConnectionNotFound) {
t.Fatalf("current retiring generation was not deleted: %v", err)
}
for _, reference := range references {
assertIdentitySecretQueueStatus(t, ctx, db, reference, "pending")
}
}
func TestConcurrentSecurityEventRetirementFinalizationQueuesOneGenerationAtomically(t *testing.T) {
db := newSecurityEventSecretCleanupPostgresStore(t)
ctx := context.Background()
connectionID := uuid.NewString()
pushReference := "ssf-push-retire-worker-" + connectionID
managementReference := "ssf-management-retire-worker-" + connectionID
nextReference := "ssf-push-next-retire-worker-" + uuid.NewString()
references := []string{pushReference, managementReference, nextReference}
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, references)
})
if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil {
t.Fatal(err)
}
for _, reference := range []string{pushReference, managementReference} {
if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
}
connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{
ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf",
EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference,
ManagementClientID: "machine-client", ManagementCredentialRef: managementReference,
IdempotencyKey: "identity-pairing-ssf-" + uuid.NewString(),
})
if err != nil {
t.Fatal(err)
}
if err := db.QueueIdentitySecretCleanup(ctx, nextReference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
connection, err = db.SetSecurityEventNextCredential(ctx, connectionID, nextReference, connection.Version)
if err != nil {
t.Fatal(err)
}
connection, err = db.TransitionSecurityEventConnectionLifecycle(
ctx, connectionID, connection.LifecycleStatus, connection.Version, "retiring", nil,
)
if err != nil {
t.Fatal(err)
}
start := make(chan struct{})
results := make(chan error, 2)
var workers sync.WaitGroup
for range 2 {
workers.Add(1)
go func() {
defer workers.Done()
<-start
results <- db.FinalizeRetiringSecurityEventConnection(ctx, connectionID, connection.Version)
}()
}
close(start)
workers.Wait()
close(results)
succeeded, conflicted := 0, 0
for err := range results {
switch {
case err == nil:
succeeded++
case errors.Is(err, ErrSecurityEventConnectionConflict):
conflicted++
default:
t.Fatalf("unexpected concurrent finalization error: %v", err)
}
}
if succeeded != 1 || conflicted != 1 {
t.Fatalf("concurrent finalization succeeded=%d conflicted=%d, want 1/1", succeeded, conflicted)
}
if _, err := db.SecurityEventConnection(ctx); !errors.Is(err, ErrSecurityEventConnectionNotFound) {
t.Fatalf("finalized connection still exists: %v", err)
}
for _, reference := range references {
assertIdentitySecretQueueStatus(t, ctx, db, reference, "pending")
}
}
func TestClaimedStagingSecretsCannotBeAdoptedByAConnection(t *testing.T) {
db := newSecurityEventSecretCleanupPostgresStore(t)
ctx := context.Background()
connectionID := uuid.NewString()
pushReference := "ssf-push-claimed-" + uuid.NewString()
managementReference := "ssf-management-claimed-" + uuid.NewString()
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`,
[]string{pushReference, managementReference})
})
if err := db.QueueIdentitySecretCleanup(ctx, pushReference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
if err := db.QueueIdentitySecretCleanup(ctx, managementReference, time.Now().Add(-time.Minute)); err != nil {
t.Fatal(err)
}
claims, err := db.ClaimIdentitySecretCleanups(ctx, 1, time.Minute)
if err != nil || len(claims) != 1 || claims[0].Reference != managementReference {
t.Fatalf("claim staging management Secret: count=%d reference_matches=%t err=%v",
len(claims), len(claims) == 1 && claims[0].Reference == managementReference, err)
}
_, err = db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{
ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf",
EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference,
ManagementClientID: "machine-client", ManagementCredentialRef: managementReference,
IdempotencyKey: "identity-pairing-ssf-" + uuid.NewString(),
})
if !errors.Is(err, ErrIdentitySecretCleanupConflict) {
t.Fatalf("claimed Secret adoption error=%v, want cleanup conflict", err)
}
if _, err := db.SecurityEventConnection(ctx); !errors.Is(err, ErrSecurityEventConnectionNotFound) {
t.Fatalf("failed adoption committed connection: %v", err)
}
assertIdentitySecretQueueStatus(t, ctx, db, pushReference, "pending")
assertIdentitySecretQueueStatus(t, ctx, db, managementReference, "claimed")
}
func TestIdentitySecretCleanupCompletionRejectsExpiredClaimABA(t *testing.T) {
db := newSecurityEventSecretCleanupPostgresStore(t)
ctx := context.Background()
reference := "ssf-management-aba-" + uuid.NewString()
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=$1`, reference)
})
if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(-time.Minute)); err != nil {
t.Fatal(err)
}
first, err := db.ClaimIdentitySecretCleanups(ctx, 1, time.Minute)
if err != nil || len(first) != 1 {
t.Fatalf("first claim count=%d err=%v", len(first), err)
}
if _, err := db.pool.Exec(ctx, `UPDATE gateway_identity_secret_cleanup_queue SET lease_expires_at=now()-interval '1 second' WHERE secret_ref=$1`, reference); err != nil {
t.Fatal(err)
}
second, err := db.ClaimIdentitySecretCleanups(ctx, 1, time.Minute)
if err != nil || len(second) != 1 || second[0].ClaimToken == first[0].ClaimToken {
t.Fatalf("second claim count=%d token_rotated=%t err=%v",
len(second), len(second) == 1 && second[0].ClaimToken != first[0].ClaimToken, err)
}
completed, err := db.CompleteIdentitySecretCleanup(ctx, reference, first[0].ClaimToken)
if err != nil || completed {
t.Fatalf("stale completion completed=%t err=%v", completed, err)
}
assertIdentitySecretQueueStatus(t, ctx, db, reference, "claimed")
completed, err = db.CompleteIdentitySecretCleanup(ctx, reference, second[0].ClaimToken)
if err != nil || !completed {
t.Fatalf("current completion completed=%t err=%v", completed, err)
}
}
func TestConcurrentIdentitySecretCleanupClaimsAreDisjoint(t *testing.T) {
db := newSecurityEventSecretCleanupPostgresStore(t)
ctx := context.Background()
references := make([]string, 20)
for index := range references {
references[index] = "ssf-cleanup-concurrent-" + uuid.NewString()
if err := db.QueueIdentitySecretCleanup(ctx, references[index], time.Now().Add(-time.Minute)); err != nil {
t.Fatal(err)
}
}
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, references)
})
start := make(chan struct{})
results := make(chan []IdentitySecretCleanupClaim, 2)
errorsCh := make(chan error, 2)
var workers sync.WaitGroup
for range 2 {
workers.Add(1)
go func() {
defer workers.Done()
<-start
claims, err := db.ClaimIdentitySecretCleanups(ctx, len(references), time.Minute)
results <- claims
errorsCh <- err
}()
}
close(start)
workers.Wait()
close(results)
close(errorsCh)
for err := range errorsCh {
if err != nil {
t.Fatal(err)
}
}
claimed := map[string]string{}
for claims := range results {
for _, claim := range claims {
if previous := claimed[claim.Reference]; previous != "" {
t.Fatalf("Secret %q was claimed by more than one worker", claim.Reference)
}
claimed[claim.Reference] = claim.ClaimToken
}
}
if len(claimed) != len(references) {
t.Fatalf("claimed %d Secrets, want %d", len(claimed), len(references))
}
}
func newSecurityEventSecretCleanupPostgresStore(t *testing.T) *Store {
t.Helper()
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run identity Secret cleanup PostgreSQL integration tests")
}
ctx := context.Background()
probe, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
var databaseName string
if err := probe.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil {
probe.Close()
t.Fatal(err)
}
probe.Close()
if !strings.Contains(strings.ToLower(databaseName), "test") {
t.Fatalf("refusing to migrate non-test database %q", databaseName)
}
applyOIDCJITTestMigrations(t, ctx, databaseURL)
db, err := Connect(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
return db
}
func assertIdentitySecretQueueState(t *testing.T, ctx context.Context, db *Store, reference string, expected bool) {
t.Helper()
var exists bool
if err := db.pool.QueryRow(ctx, `SELECT EXISTS(
SELECT 1 FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=$1)`, reference).Scan(&exists); err != nil {
t.Fatal(err)
}
if exists != expected {
t.Fatalf("Secret cleanup reference %q exists=%t want=%t", reference, exists, expected)
}
}
func assertIdentitySecretQueueStatus(t *testing.T, ctx context.Context, db *Store, reference, expected string) {
t.Helper()
var status string
if err := db.pool.QueryRow(ctx, `SELECT status FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=$1`, reference).Scan(&status); err != nil {
t.Fatal(err)
}
if status != expected {
t.Fatalf("Secret cleanup reference %q status=%q want=%q", reference, status, expected)
}
}

View File

@ -0,0 +1,372 @@
package store
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
type ApplySessionRevokedInput struct {
Issuer string
Audience string
JTI string
TransactionID string
SubjectIssuer string
TenantID string
Subject string
EventTimestamp time.Time
InitiatingEntity string
}
type ApplySecurityEventResult struct {
Duplicate bool
WatermarkMoved bool
SessionsDeleted int64
AuditID string
}
type SecurityEventEvaluation struct {
Revoked bool
RequireIntrospection bool
Mode string
}
func (s *Store) ApplySessionRevoked(ctx context.Context, input ApplySessionRevokedInput) (ApplySecurityEventResult, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return ApplySecurityEventResult{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
subjectHash := shortSecurityEventHash(input.Subject)
tag, err := tx.Exec(ctx, `
INSERT INTO gateway_security_event_receipts (
issuer, jti, audience, event_type, transaction_id, tenant_id, subject_hash, event_timestamp
)
VALUES ($1, $2::uuid, $3, $4, NULLIF($5, ''), $6, $7, $8)
ON CONFLICT (issuer, jti) DO NOTHING`,
input.Issuer, input.JTI, input.Audience,
"https://schemas.openid.net/secevent/caep/event-type/session-revoked",
input.TransactionID, input.TenantID, subjectHash, input.EventTimestamp,
)
if err != nil {
return ApplySecurityEventResult{}, err
}
if tag.RowsAffected() == 0 {
if err := tx.Commit(ctx); err != nil {
return ApplySecurityEventResult{}, err
}
return ApplySecurityEventResult{Duplicate: true}, nil
}
var revokedAt time.Time
err = tx.QueryRow(ctx, `
INSERT INTO gateway_oidc_revocation_watermarks (issuer, tenant_id, subject, revoked_at, source_jti)
VALUES ($1, $2, $3, $4, $5::uuid)
ON CONFLICT (issuer, tenant_id, subject) DO UPDATE
SET revoked_at = EXCLUDED.revoked_at, source_jti = EXCLUDED.source_jti, updated_at = now()
WHERE EXCLUDED.revoked_at > gateway_oidc_revocation_watermarks.revoked_at
RETURNING revoked_at`, input.SubjectIssuer, input.TenantID, input.Subject, input.EventTimestamp, input.JTI).Scan(&revokedAt)
watermarkMoved := err == nil
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return ApplySecurityEventResult{}, err
}
var sessionsDeleted int64
if watermarkMoved {
tag, err = tx.Exec(ctx, `
DELETE FROM gateway_oidc_sessions session
USING gateway_users gateway_user
WHERE session.gateway_user_id = gateway_user.id
AND gateway_user.source = 'oidc'
AND gateway_user.external_user_id = $1
AND gateway_user.tenant_id = $2`, input.Subject, input.TenantID)
if err != nil {
return ApplySecurityEventResult{}, err
}
sessionsDeleted = tag.RowsAffected()
}
jtiHash := shortSecurityEventHash(input.JTI)
audit, err := s.RecordAuditLogTx(ctx, tx, AuditLogInput{
Category: "identity", Action: "identity.oidc_session.revoked", ActorSource: "ssf",
TargetType: "oidc_subject", TargetID: subjectHash,
AfterState: map[string]any{"watermarkMoved": watermarkMoved, "sessionsDeleted": sessionsDeleted},
Metadata: map[string]any{
"issuer": input.SubjectIssuer, "transmitterIssuer": input.Issuer, "tenantId": input.TenantID, "jtiHash": jtiHash,
"initiatingEntity": input.InitiatingEntity,
},
})
if err != nil {
return ApplySecurityEventResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return ApplySecurityEventResult{}, err
}
return ApplySecurityEventResult{WatermarkMoved: watermarkMoved, SessionsDeleted: sessionsDeleted, AuditID: audit.ID}, nil
}
func (s *Store) EnsureSecurityEventStreamState(ctx context.Context, issuer, audience, streamID string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
INSERT INTO gateway_security_event_stream_state(issuer,audience,stream_id,mode)
VALUES($1,$2,$3::uuid,'bootstrap')
ON CONFLICT(issuer,audience) DO NOTHING`, issuer, audience, streamID); err != nil {
return err
}
var currentStreamID string
if err := tx.QueryRow(ctx, `SELECT stream_id::text FROM gateway_security_event_stream_state
WHERE issuer=$1 AND audience=$2 FOR UPDATE`, issuer, audience).Scan(&currentStreamID); err != nil {
return err
}
if currentStreamID != streamID {
if _, err := tx.Exec(ctx, `DELETE FROM gateway_security_event_verification_challenges
WHERE issuer=$1 AND audience=$2`, issuer, audience); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
UPDATE gateway_security_event_stream_state
SET stream_id=$3::uuid,mode='bootstrap',stream_status='unknown',
last_verification_at=NULL,bootstrap_until=NULL,consecutive_verifications=0,
pending_state_hash=NULL,pending_state_created_at=NULL,fallback_since=NULL,
last_error_category=NULL,created_at=now(),updated_at=now()
WHERE issuer=$1 AND audience=$2`, issuer, audience, streamID); err != nil {
return err
}
}
return tx.Commit(ctx)
}
func (s *Store) BeginSecurityEventVerification(ctx context.Context, issuer, audience string, stateHash []byte, now time.Time) error {
if len(stateHash) != sha256.Size {
return errors.New("verification state hash must be SHA-256")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
tag, err := tx.Exec(ctx, `
UPDATE gateway_security_event_stream_state
SET pending_state_hash=$3,pending_state_created_at=$4,updated_at=now()
WHERE issuer=$1 AND audience=$2`, issuer, audience, stateHash, now)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errors.New("security event stream state is missing")
}
if _, err := tx.Exec(ctx, `
INSERT INTO gateway_security_event_verification_challenges(issuer,audience,state_hash,created_at,expires_at)
VALUES($1,$2,$3,$4::timestamptz,$4::timestamptz + interval '180 seconds')
ON CONFLICT(issuer,audience,state_hash) DO NOTHING`, issuer, audience, stateHash, now); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM gateway_security_event_verification_challenges WHERE expires_at < $1`, now); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Store) ConfirmSecurityEventVerification(ctx context.Context, issuer, audience, streamID, jti string, stateHash []byte, now time.Time) (bool, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return false, err
}
defer func() { _ = tx.Rollback(ctx) }()
tag, err := tx.Exec(ctx, `
INSERT INTO gateway_security_event_receipts(issuer,jti,audience,event_type,subject_hash)
VALUES($1,$2::uuid,$3,$4,$5)
ON CONFLICT(issuer,jti) DO NOTHING`, issuer, jti, audience,
"https://schemas.openid.net/secevent/ssf/event-type/verification", shortSecurityEventHash(streamID))
if err != nil {
return false, err
}
if tag.RowsAffected() == 0 {
return false, tx.Commit(ctx)
}
tag, err = tx.Exec(ctx, `
UPDATE gateway_security_event_stream_state
SET last_verification_at=$5,
bootstrap_until=CASE WHEN last_verification_at IS NULL THEN $5 + interval '360 seconds' ELSE bootstrap_until END,
consecutive_verifications=CASE
WHEN stream_status <> 'enabled' THEN 0
WHEN mode='introspection_fallback' THEN consecutive_verifications+1
ELSE 1
END,
mode=CASE
WHEN stream_status <> 'enabled' THEN mode
WHEN mode='introspection_fallback' AND consecutive_verifications+1 >= 2
AND (bootstrap_until IS NULL OR $5 >= bootstrap_until) THEN 'push_healthy'
WHEN mode='introspection_fallback' THEN 'introspection_fallback'
WHEN mode='push_healthy' THEN 'push_healthy'
ELSE 'bootstrap'
END,
fallback_since=CASE
WHEN stream_status='enabled' AND mode='introspection_fallback' AND consecutive_verifications+1 >= 2
AND (bootstrap_until IS NULL OR $5 >= bootstrap_until) THEN NULL
ELSE fallback_since
END,
pending_state_hash=NULL,pending_state_created_at=NULL,
last_error_category=CASE WHEN stream_status='enabled' THEN NULL ELSE 'stream_' || stream_status END,
updated_at=now()
WHERE issuer=$1 AND audience=$2 AND stream_id=$3::uuid
AND EXISTS(SELECT 1 FROM gateway_security_event_verification_challenges challenge
WHERE challenge.issuer=$1 AND challenge.audience=$2 AND challenge.state_hash=$4 AND challenge.expires_at >= $5)`,
issuer, audience, streamID, stateHash, now)
if err != nil {
return false, err
}
if tag.RowsAffected() == 0 {
return false, errors.New("invalid verification state")
}
if _, err := tx.Exec(ctx, `DELETE FROM gateway_security_event_verification_challenges
WHERE issuer=$1 AND audience=$2 AND state_hash=$3`, issuer, audience, stateHash); err != nil {
return false, err
}
if _, err := tx.Exec(ctx, `
UPDATE gateway_security_event_connections connection
SET lifecycle_status='enabled',last_error_category=NULL,version=version+1,updated_at=now()
WHERE connection.transmitter_issuer=$1 AND connection.audience=$2
AND connection.lifecycle_status='degraded'
AND EXISTS(
SELECT 1 FROM gateway_security_event_stream_state state
WHERE state.issuer=$1 AND state.audience=$2
AND state.mode='push_healthy' AND state.stream_status='enabled'
)`, issuer, audience); err != nil {
return false, err
}
return true, tx.Commit(ctx)
}
func (s *Store) RecordSecurityEventReceipt(ctx context.Context, issuer, audience, jti, eventType, subject string) (bool, error) {
tag, err := s.pool.Exec(ctx, `
INSERT INTO gateway_security_event_receipts(issuer,jti,audience,event_type,subject_hash)
VALUES($1,$2::uuid,$3,$4,$5)
ON CONFLICT(issuer,jti) DO NOTHING`, issuer, jti, audience, eventType, shortSecurityEventHash(subject))
return tag.RowsAffected() == 1, err
}
// ApplySecurityEventStreamUpdated records a transmitter-originated stream
// status change and immediately moves OIDC traffic to introspection. Push is
// trusted again only after the normal consecutive-verification recovery path.
func (s *Store) ApplySecurityEventStreamUpdated(ctx context.Context, issuer, audience, jti, streamID, status, reason string, now time.Time) (bool, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return false, err
}
defer func() { _ = tx.Rollback(ctx) }()
tag, err := tx.Exec(ctx, `
INSERT INTO gateway_security_event_receipts(issuer,jti,audience,event_type,subject_hash)
VALUES($1,$2::uuid,$3,$4,$5)
ON CONFLICT(issuer,jti) DO NOTHING`, issuer, jti, audience,
"https://schemas.openid.net/secevent/ssf/event-type/stream-updated", shortSecurityEventHash(streamID))
if err != nil {
return false, err
}
if tag.RowsAffected() == 0 {
return false, tx.Commit(ctx)
}
tag, err = tx.Exec(ctx, `
UPDATE gateway_security_event_stream_state
SET stream_status=$5,mode='introspection_fallback',fallback_since=COALESCE(fallback_since,$4),
consecutive_verifications=0,last_error_category=$6,updated_at=now()
WHERE issuer=$1 AND audience=$2 AND stream_id=$3::uuid`, issuer, audience, streamID, now, status, "stream_"+status)
if err != nil {
return false, err
}
if tag.RowsAffected() == 0 {
return false, errors.New("security event stream state is missing")
}
if _, err := s.RecordAuditLogTx(ctx, tx, AuditLogInput{
Category: "identity", Action: "identity.ssf_stream.updated", ActorSource: "ssf",
TargetType: "ssf_stream", TargetID: shortSecurityEventHash(streamID),
AfterState: map[string]any{"status": status},
Metadata: map[string]any{"issuer": issuer, "audience": audience, "reasonCategory": shortSecurityEventHash(reason)},
}); err != nil {
return false, err
}
return true, tx.Commit(ctx)
}
func (s *Store) RecordSecurityEventHeartbeatFailure(ctx context.Context, issuer, audience, category string) error {
_, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state
SET last_error_category=$3,updated_at=now() WHERE issuer=$1 AND audience=$2`, issuer, audience, category)
return err
}
func (s *Store) AdvanceSecurityEventStreamState(ctx context.Context, issuer, audience string, now time.Time, staleAfter time.Duration) error {
if staleAfter <= 0 {
return errors.New("security event stale threshold must be positive")
}
_, err := s.pool.Exec(ctx, `
UPDATE gateway_security_event_stream_state
SET mode='introspection_fallback',fallback_since=COALESCE(fallback_since,$3),
consecutive_verifications=0,last_error_category='verification_stale',updated_at=now()
WHERE issuer=$1 AND audience=$2 AND mode <> 'introspection_fallback'
AND COALESCE(last_verification_at,created_at) < $3::timestamptz - make_interval(secs => $4::double precision)`,
issuer, audience, now, staleAfter.Seconds())
return err
}
func (s *Store) EvaluateOIDCSecurityEvent(ctx context.Context, transmitterIssuer, audience, subjectIssuer, tenantID, subject string, issuedAt, now time.Time, staleAfter time.Duration) (SecurityEventEvaluation, error) {
var mode, streamStatus string
var lastVerification, bootstrapUntil *time.Time
var createdAt time.Time
var revokedAt *time.Time
err := s.pool.QueryRow(ctx, `
SELECT state.mode,state.stream_status,state.last_verification_at,state.bootstrap_until,state.created_at,watermark.revoked_at
FROM gateway_security_event_stream_state state
LEFT JOIN gateway_oidc_revocation_watermarks watermark
ON watermark.issuer=$3 AND watermark.tenant_id=$4 AND watermark.subject=$5
WHERE state.issuer=$1 AND state.audience=$2`, transmitterIssuer, audience, subjectIssuer, tenantID, subject).Scan(
&mode, &streamStatus, &lastVerification, &bootstrapUntil, &createdAt, &revokedAt,
)
if err != nil {
return SecurityEventEvaluation{}, err
}
evaluation := SecurityEventEvaluation{Mode: mode, Revoked: revokedAt != nil && !issuedAt.After(*revokedAt)}
stale := (lastVerification != nil && now.Sub(*lastVerification) > staleAfter) ||
(lastVerification == nil && (mode != "bootstrap" || now.Sub(createdAt) > staleAfter))
if stale {
evaluation.Mode, evaluation.RequireIntrospection = "introspection_fallback", true
_, err := s.pool.Exec(ctx, `
UPDATE gateway_security_event_stream_state
SET mode='introspection_fallback',fallback_since=COALESCE(fallback_since,$3),consecutive_verifications=0,
last_error_category='verification_stale',updated_at=now()
WHERE issuer=$1 AND audience=$2 AND mode <> 'introspection_fallback'`, transmitterIssuer, audience, now)
return evaluation, err
}
if mode == "bootstrap" {
if streamStatus != "enabled" || bootstrapUntil == nil || now.Before(*bootstrapUntil) {
evaluation.RequireIntrospection = true
return evaluation, nil
}
evaluation.Mode = "push_healthy"
_, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state SET mode='push_healthy',updated_at=now()
WHERE issuer=$1 AND audience=$2 AND mode='bootstrap'`, transmitterIssuer, audience)
return evaluation, err
}
evaluation.RequireIntrospection = mode == "introspection_fallback"
return evaluation, nil
}
func (s *Store) SecurityEventMetrics(ctx context.Context, issuer, audience string) (string, *time.Time, error) {
var mode string
var lastVerificationAt *time.Time
err := s.pool.QueryRow(ctx, `
SELECT mode,last_verification_at
FROM gateway_security_event_stream_state
WHERE issuer=$1 AND audience=$2`, issuer, audience).Scan(&mode, &lastVerificationAt)
return mode, lastVerificationAt, err
}
func shortSecurityEventHash(value string) string {
digest := sha256.Sum256([]byte(value))
return hex.EncodeToString(digest[:])[:16]
}

View File

@ -0,0 +1,185 @@
package store
import (
"context"
"crypto/sha256"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run security event PostgreSQL integration tests")
}
ctx := context.Background()
probe, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
var databaseName string
if err := probe.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil {
probe.Close()
t.Fatal(err)
}
probe.Close()
if !strings.Contains(strings.ToLower(databaseName), "test") {
t.Fatalf("refusing to migrate non-test database %q", databaseName)
}
applyOIDCJITTestMigrations(t, ctx, databaseURL)
db, err := Connect(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer db.Close()
issuer := "https://auth.test.example/ssf"
subjectIssuer := "https://auth.test/realms/tenant"
audience := "urn:easyai:ssf:receiver:" + uuid.NewString()
streamID, tenantID, subject := uuid.NewString(), uuid.NewString(), uuid.NewString()
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE transmitter_issuer=$1`, issuer)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_receipts WHERE issuer=$1`, issuer)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_oidc_revocation_watermarks WHERE issuer=$1`, subjectIssuer)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_stream_state WHERE issuer=$1`, issuer)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_audit_logs WHERE actor_source='ssf' AND target_id=ANY($1)`, []string{shortSecurityEventHash(subject), shortSecurityEventHash(streamID)})
})
if err := db.EnsureSecurityEventStreamState(ctx, issuer, audience, streamID); err != nil {
t.Fatal(err)
}
now := time.Now().UTC().Truncate(time.Second)
if err := db.AdvanceSecurityEventStreamState(ctx, issuer, audience, now, 180*time.Second); err != nil {
t.Fatalf("advance fresh stream state: %v", err)
}
evaluation, err := db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second)
if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "bootstrap" {
t.Fatalf("bootstrap evaluation=%#v error=%v", evaluation, err)
}
confirmAt := func(label string, at time.Time) {
t.Helper()
stateHash := sha256.Sum256([]byte(label))
if err := db.BeginSecurityEventVerification(ctx, issuer, audience, stateHash[:], at); err != nil {
t.Fatal(err)
}
matched, err := db.ConfirmSecurityEventVerification(ctx, issuer, audience, streamID, uuid.NewString(), stateHash[:], at)
if err != nil || !matched {
t.Fatalf("confirm matched=%v error=%v", matched, err)
}
}
inserted, err := db.ApplySecurityEventStreamUpdated(ctx, issuer, audience, uuid.NewString(), streamID, "enabled", "onboarding", now)
if err != nil || !inserted {
t.Fatalf("enable stream inserted=%v error=%v", inserted, err)
}
confirmAt("first-verification-state", now)
confirmAt("second-verification-state", now.Add(time.Minute))
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(time.Minute), 180*time.Second)
if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" {
t.Fatalf("bootstrap overlap evaluation=%#v error=%v", evaluation, err)
}
confirmAt("post-bootstrap-verification-state", now.Add(361*time.Second))
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(361*time.Second), 180*time.Second)
if err != nil || evaluation.RequireIntrospection || evaluation.Mode != "push_healthy" {
t.Fatalf("healthy evaluation=%#v error=%v", evaluation, err)
}
streamUpdateJTI := uuid.NewString()
inserted, err = db.ApplySecurityEventStreamUpdated(ctx, issuer, audience, streamUpdateJTI, streamID, "paused", "maintenance", now.Add(362*time.Second))
if err != nil || !inserted {
t.Fatalf("stream update inserted=%v error=%v", inserted, err)
}
inserted, err = db.ApplySecurityEventStreamUpdated(ctx, issuer, audience, streamUpdateJTI, streamID, "paused", "maintenance", now)
if err != nil || inserted {
t.Fatalf("duplicate stream update inserted=%v error=%v", inserted, err)
}
confirmAt("paused-verification-one", now.Add(363*time.Second))
confirmAt("paused-verification-two", now.Add(364*time.Second))
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(364*time.Second), 180*time.Second)
if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" {
t.Fatalf("stream update fallback=%#v error=%v", evaluation, err)
}
_, _ = db.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state SET stream_status='enabled',mode='push_healthy',fallback_since=NULL,
consecutive_verifications=0,last_verification_at=$3 WHERE issuer=$1 AND audience=$2`, issuer, audience, now)
revokedAt := now.Add(-10 * time.Second)
input := ApplySessionRevokedInput{Issuer: issuer, Audience: audience, JTI: uuid.NewString(), TransactionID: uuid.NewString(), SubjectIssuer: subjectIssuer, TenantID: tenantID, Subject: subject, EventTimestamp: revokedAt, InitiatingEntity: "admin"}
result, err := db.ApplySessionRevoked(ctx, input)
if err != nil || !result.WatermarkMoved || result.AuditID == "" {
t.Fatalf("apply result=%#v error=%v", result, err)
}
duplicate, err := db.ApplySessionRevoked(ctx, input)
if err != nil || !duplicate.Duplicate {
t.Fatalf("duplicate result=%#v error=%v", duplicate, err)
}
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, revokedAt, now, 180*time.Second)
if !evaluation.Revoked {
t.Fatal("token at watermark was accepted")
}
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, "https://other.test/issuer", tenantID, subject, revokedAt, now, 180*time.Second)
if evaluation.Revoked {
t.Fatal("watermark crossed OIDC issuer boundary")
}
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second)
if evaluation.Revoked {
t.Fatal("token after watermark was rejected")
}
_, _ = db.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state SET last_verification_at=$3
WHERE issuer=$1 AND audience=$2`, issuer, audience, now.Add(-181*time.Second))
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second)
if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" {
t.Fatalf("fallback evaluation=%#v error=%v", evaluation, err)
}
connectionID := uuid.NewString()
if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil {
t.Fatal(err)
}
if _, err := db.pool.Exec(ctx, `INSERT INTO gateway_security_event_connections(
connection_id,transmitter_issuer,endpoint_url,audience,stream_id,credential_ref,
lifecycle_status,last_error_category,idempotency_key)
VALUES($1,$2,'https://gateway.test/api/v1/security-events/ssf',$3,$4,'test-secret-ref',
'degraded','bootstrap_verification_stale',$5)`, connectionID, issuer, audience, streamID, uuid.NewString()); err != nil {
t.Fatal(err)
}
confirmAt("recovery-verification-one", now.Add(362*time.Second))
confirmAt("recovery-verification-two", now.Add(363*time.Second))
var lifecycle string
var lastError *string
if err := db.pool.QueryRow(ctx, `SELECT lifecycle_status,last_error_category
FROM gateway_security_event_connections WHERE connection_id=$1`, connectionID).Scan(&lifecycle, &lastError); err != nil {
t.Fatal(err)
}
if lifecycle != "enabled" || lastError != nil {
t.Fatalf("recovered connection lifecycle=%q lastError=%v", lifecycle, lastError)
}
replacementStreamID := uuid.NewString()
if err := db.EnsureSecurityEventStreamState(ctx, issuer, audience, replacementStreamID); err != nil {
t.Fatalf("rebind retained receiver state: %v", err)
}
var reboundStreamID, reboundMode, reboundStatus string
var reboundVerification *time.Time
if err := db.pool.QueryRow(ctx, `SELECT stream_id::text,mode,stream_status,last_verification_at
FROM gateway_security_event_stream_state WHERE issuer=$1 AND audience=$2`, issuer, audience).
Scan(&reboundStreamID, &reboundMode, &reboundStatus, &reboundVerification); err != nil {
t.Fatal(err)
}
if reboundStreamID != replacementStreamID || reboundMode != "bootstrap" || reboundStatus != "unknown" || reboundVerification != nil {
t.Fatalf("rebound state stream=%q mode=%q status=%q verification=%v",
reboundStreamID, reboundMode, reboundStatus, reboundVerification)
}
var retainedReceipts, retainedWatermarks int
if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_security_event_receipts
WHERE issuer=$1`, issuer).Scan(&retainedReceipts); err != nil {
t.Fatal(err)
}
if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_oidc_revocation_watermarks
WHERE issuer=$1 AND tenant_id=$2 AND subject=$3`, subjectIssuer, tenantID, subject).Scan(&retainedWatermarks); err != nil {
t.Fatal(err)
}
if retainedReceipts == 0 || retainedWatermarks != 1 {
t.Fatalf("rebind discarded security history receipts=%d watermarks=%d", retainedReceipts, retainedWatermarks)
}
}

View File

@ -0,0 +1,10 @@
package store
import "testing"
func TestSecurityEventHashesAreStableAndSanitized(t *testing.T) {
first := shortSecurityEventHash("sensitive-subject")
if first != shortSecurityEventHash("sensitive-subject") || len(first) != 16 || first == "sensitive-subject" {
t.Fatalf("unsafe or unstable hash: %q", first)
}
}

View File

@ -0,0 +1,70 @@
CREATE TABLE IF NOT EXISTS gateway_security_event_receipts (
issuer text NOT NULL,
jti uuid NOT NULL,
audience text NOT NULL,
event_type text NOT NULL,
transaction_id text,
tenant_id text,
subject_hash text,
event_timestamp timestamptz,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (issuer, jti)
);
CREATE INDEX IF NOT EXISTS idx_gateway_security_event_receipts_received
ON gateway_security_event_receipts(received_at DESC);
CREATE TABLE IF NOT EXISTS gateway_oidc_revocation_watermarks (
issuer text NOT NULL,
tenant_id text NOT NULL,
subject text NOT NULL,
revoked_at timestamptz NOT NULL,
source_jti uuid NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (issuer, tenant_id, subject)
);
CREATE INDEX IF NOT EXISTS idx_gateway_oidc_revocation_watermarks_lookup
ON gateway_oidc_revocation_watermarks(issuer, tenant_id, subject, revoked_at);
CREATE TABLE IF NOT EXISTS gateway_security_event_stream_state (
issuer text NOT NULL,
audience text NOT NULL,
stream_id uuid NOT NULL,
stream_status text NOT NULL DEFAULT 'unknown',
mode text NOT NULL DEFAULT 'bootstrap',
last_verification_at timestamptz,
bootstrap_until timestamptz,
consecutive_verifications integer NOT NULL DEFAULT 0,
pending_state_hash bytea,
pending_state_created_at timestamptz,
fallback_since timestamptz,
last_error_category text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (issuer, audience),
UNIQUE (stream_id),
CONSTRAINT gateway_security_event_stream_state_mode
CHECK (mode IN ('bootstrap','push_healthy','introspection_fallback')),
CONSTRAINT gateway_security_event_stream_state_status
CHECK (stream_status IN ('unknown','enabled','paused','disabled')),
CONSTRAINT gateway_security_event_stream_state_pending_hash
CHECK (pending_state_hash IS NULL OR octet_length(pending_state_hash) = 32)
);
CREATE TABLE IF NOT EXISTS gateway_security_event_verification_challenges (
issuer text NOT NULL,
audience text NOT NULL,
state_hash bytea NOT NULL,
created_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL,
PRIMARY KEY (issuer, audience, state_hash),
FOREIGN KEY (issuer, audience)
REFERENCES gateway_security_event_stream_state(issuer, audience) ON DELETE CASCADE,
CONSTRAINT gateway_security_event_verification_challenge_hash
CHECK (octet_length(state_hash) = 32)
);
CREATE INDEX IF NOT EXISTS idx_gateway_security_event_challenges_expiry
ON gateway_security_event_verification_challenges(expires_at);

View File

@ -0,0 +1,46 @@
CREATE TABLE IF NOT EXISTS gateway_security_event_connections (
connection_id uuid PRIMARY KEY,
singleton boolean NOT NULL DEFAULT true UNIQUE CHECK (singleton),
transmitter_issuer text NOT NULL,
endpoint_url text NOT NULL,
audience text,
stream_id uuid,
credential_ref text NOT NULL,
next_credential_ref text,
management_client_id text,
management_credential_ref text,
lifecycle_status text NOT NULL,
version bigint NOT NULL DEFAULT 1,
last_error_category text,
idempotency_key text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT gateway_security_event_connection_status CHECK (lifecycle_status IN (
'connecting','verifying','bootstrap','enabled','degraded','rotating',
'disconnect_pending','retiring','error'
)),
CONSTRAINT gateway_security_event_connection_stream_pair CHECK (
(audience IS NULL AND stream_id IS NULL) OR (audience IS NOT NULL AND stream_id IS NOT NULL)
),
CONSTRAINT gateway_security_event_management_credential_pair CHECK (
(management_client_id IS NULL AND management_credential_ref IS NULL) OR
(management_client_id IS NOT NULL AND management_credential_ref IS NOT NULL)
)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_gateway_security_event_connections_idempotency
ON gateway_security_event_connections(idempotency_key);
CREATE TABLE IF NOT EXISTS gateway_security_event_connection_idempotency (
operation text NOT NULL,
idempotency_key text NOT NULL,
request_hash text NOT NULL,
response jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (operation, idempotency_key),
CONSTRAINT gateway_security_event_connection_idempotency_operation
CHECK (operation IN ('connect','verify','rotate','disconnect'))
);
CREATE INDEX IF NOT EXISTS idx_gateway_security_event_connection_idempotency_created
ON gateway_security_event_connection_idempotency(created_at);

View File

@ -0,0 +1,56 @@
CREATE TABLE IF NOT EXISTS gateway_identity_configuration_revisions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
state text NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','validated','active','superseded','failed')),
schema_version integer NOT NULL DEFAULT 1 CHECK (schema_version = 1),
auth_center_url text NOT NULL,
issuer text,
tenant_id text,
application_id text,
audience text,
browser_client_id text,
machine_client_id text,
scopes jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(scopes) = 'array'),
capabilities jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(capabilities) = 'array'),
role_prefix text NOT NULL DEFAULT 'gateway.',
local_tenant_key text NOT NULL,
public_base_url text NOT NULL,
web_base_url text NOT NULL,
jit_enabled boolean NOT NULL DEFAULT true,
legacy_jwt_enabled boolean NOT NULL DEFAULT false,
token_introspection boolean NOT NULL DEFAULT false,
session_revocation boolean NOT NULL DEFAULT false,
security_event_issuer text,
security_event_configuration_url text,
security_event_audience text,
machine_credential_ref text,
session_encryption_key_ref text,
session_idle_seconds integer NOT NULL DEFAULT 1800 CHECK (session_idle_seconds > 0),
session_absolute_seconds integer NOT NULL DEFAULT 28800 CHECK (session_absolute_seconds > session_idle_seconds),
session_refresh_seconds integer NOT NULL DEFAULT 60 CHECK (session_refresh_seconds > 0 AND session_refresh_seconds < session_idle_seconds),
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
last_error_category text,
last_trace_id text,
last_audit_id text,
validated_at timestamptz,
activated_at timestamptz,
superseded_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (machine_credential_ref IS NULL OR machine_credential_ref ~ '^[a-z0-9][a-z0-9-]{0,127}$'),
CHECK (session_encryption_key_ref IS NULL OR session_encryption_key_ref ~ '^[a-z0-9][a-z0-9-]{0,127}$')
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_gateway_identity_configuration_single_active
ON gateway_identity_configuration_revisions ((true)) WHERE state = 'active';
CREATE INDEX IF NOT EXISTS idx_gateway_identity_configuration_revisions_created
ON gateway_identity_configuration_revisions(created_at DESC);
CREATE TABLE IF NOT EXISTS gateway_identity_management_requests (
operation text NOT NULL,
idempotency_key text NOT NULL,
request_hash text NOT NULL,
response jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (operation, idempotency_key)
);

View File

@ -0,0 +1,55 @@
CREATE TABLE IF NOT EXISTS gateway_identity_onboarding_exchanges (
id uuid PRIMARY KEY,
revision_id uuid NOT NULL UNIQUE REFERENCES gateway_identity_configuration_revisions(id) ON DELETE CASCADE,
remote_exchange_id uuid NOT NULL UNIQUE,
exchange_token_ref text NOT NULL CHECK (exchange_token_ref ~ '^[a-z0-9][a-z0-9-]{0,127}$'),
status text NOT NULL CHECK (status IN (
'metadata_pending','preparing','ready','credentials_saved','completed','failed','expired','cancelled'
)),
cleanup_status text NOT NULL DEFAULT 'none' CHECK (cleanup_status IN ('none','pending','completed')),
remote_version bigint NOT NULL CHECK (remote_version > 0),
expires_at timestamptz NOT NULL,
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
last_error_category text,
auth_center_audit_id text,
last_trace_id text,
cancelled_at timestamptz,
cleanup_completed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT gateway_identity_onboarding_cleanup_lifecycle_check CHECK (
(status <> 'cancelled' AND cleanup_status = 'none' AND cancelled_at IS NULL AND cleanup_completed_at IS NULL) OR
(status = 'cancelled' AND cancelled_at IS NOT NULL AND (
(cleanup_status = 'pending' AND cleanup_completed_at IS NULL) OR
(cleanup_status = 'completed' AND cleanup_completed_at IS NOT NULL)
))
),
CONSTRAINT gateway_identity_onboarding_error_category_check CHECK (
last_error_category IS NULL OR last_error_category IN (
'metadata_submission_failed','exchange_status_unavailable','credential_delivery_failed',
'exchange_completion_failed','exchange_expired','remote_exchange_failed','remote_state_invalid',
'security_event_configuration_invalid','security_event_connection_conflict',
'security_event_discovery_failed','security_event_management_token_failed',
'security_event_stream_create_failed','security_event_stream_response_invalid',
'security_event_receiver_activation_failed','security_event_preparation_failed',
'security_event_retirement_pending','security_event_credential_handoff_unsafe',
'security_event_connection_binding_missing','security_event_connection_binding_unavailable',
'security_event_connection_binding_invalid','security_event_connection_binding_mismatch',
'pairing_step_failed','cleanup_revision_unavailable','cleanup_security_event_unavailable',
'cleanup_security_event_configuration_invalid','cleanup_security_event_connection_conflict',
'cleanup_security_event_discovery_failed','cleanup_security_event_management_token_failed',
'cleanup_security_event_stream_create_failed','cleanup_security_event_stream_response_invalid',
'cleanup_security_event_receiver_activation_failed','cleanup_security_event_preparation_failed',
'cleanup_security_event_retirement_pending','cleanup_security_event_connection_cleanup_failed',
'cleanup_security_event_secret_cleanup_failed','cleanup_security_event_credential_handoff_unsafe',
'cleanup_security_event_failed','cleanup_secret_store_failed','cleanup_finalize_failed'
)
)
);
CREATE INDEX IF NOT EXISTS idx_gateway_identity_onboarding_status
ON gateway_identity_onboarding_exchanges(status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_gateway_identity_onboarding_cleanup
ON gateway_identity_onboarding_exchanges(cleanup_status, updated_at)
WHERE cleanup_status = 'pending';

View File

@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS gateway_identity_secret_cleanup_queue (
secret_ref text PRIMARY KEY CHECK (secret_ref ~ '^[a-z0-9][a-z0-9-]{0,127}$'),
not_before timestamptz NOT NULL,
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','claimed')),
claim_token uuid,
lease_expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT gateway_identity_secret_cleanup_claim_check CHECK (
(status = 'pending' AND claim_token IS NULL AND lease_expires_at IS NULL) OR
(status = 'claimed' AND claim_token IS NOT NULL AND lease_expires_at IS NOT NULL)
)
);
CREATE INDEX IF NOT EXISTS idx_gateway_identity_secret_cleanup_due
ON gateway_identity_secret_cleanup_queue(status, not_before, lease_expires_at, updated_at);

View File

@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS gateway_identity_pairing_start_reservation (
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
attempt_id uuid NOT NULL UNIQUE,
state text NOT NULL DEFAULT 'starting' CHECK (state IN ('starting','paired')),
revision_id uuid UNIQUE REFERENCES gateway_identity_configuration_revisions(id) ON DELETE RESTRICT,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT gateway_identity_pairing_start_state_check CHECK (
(state='starting' AND revision_id IS NULL) OR
(state='paired' AND revision_id IS NOT NULL)
)
);
CREATE INDEX IF NOT EXISTS idx_gateway_identity_pairing_start_expiry
ON gateway_identity_pairing_start_reservation(state, expires_at);
INSERT INTO gateway_identity_pairing_start_reservation(singleton,attempt_id,state,revision_id,expires_at)
SELECT true,exchange.id,'paired',revision.id,exchange.expires_at
FROM gateway_identity_configuration_revisions revision
JOIN gateway_identity_onboarding_exchanges exchange ON exchange.revision_id=revision.id
WHERE revision.state IN ('draft','validated','failed')
AND NOT (exchange.status='cancelled' AND exchange.cleanup_status='completed')
ORDER BY revision.created_at DESC,exchange.created_at DESC
LIMIT 1
ON CONFLICT DO NOTHING;

View File

@ -24,7 +24,7 @@
"outputs": ["{projectRoot}/docs/swagger.json", "{projectRoot}/docs/swagger.yaml"],
"options": {
"cwd": "apps/api",
"command": "go run github.com/swaggo/swag/cmd/swag@v1.16.4 init --parseInternal -d ./cmd/gateway,./internal/httpapi,./internal/store,./internal/auth -g main.go -o docs --outputTypes json,yaml"
"command": "go run github.com/swaggo/swag/cmd/swag@v1.16.4 init --parseInternal -d ./cmd/gateway,./internal/httpapi,./internal/store,./internal/auth,./internal/identity -g main.go -o docs --outputTypes json,yaml"
}
},
"test": {

View File

@ -32,6 +32,7 @@ import type {
PricingRuleSet,
RateLimitWindow,
RuntimePolicySet,
SecurityEventConnectionResponse,
UserGroupUpsertRequest,
UserGroup,
WalletRechargeRequest,
@ -46,6 +47,7 @@ import {
createPlatform,
createTenant,
createUserGroup,
connectSecurityEventTransmitter,
deleteAccessRule,
deleteApiKey,
deleteFileStorageChannel,
@ -53,6 +55,7 @@ import {
deletePlatform,
deleteTenant,
deleteUserGroup,
disconnectSecurityEventConnection,
GatewayApiError,
getClientCustomizationSettings,
getHealth,
@ -61,6 +64,7 @@ import {
getFileStorageSettings,
getNetworkProxyConfig,
getRunnerPolicy,
getSecurityEventConnection,
getWalletSummary,
listAccessRules,
listAuditLogs,
@ -93,6 +97,7 @@ import {
rechargeUserWalletBalance,
replacePlatformModels,
restoreModelRuntimeStatus,
rotateSecurityEventConnectionCredential,
type HealthResponse,
updateAccessRule,
updateApiKeyScopes,
@ -104,6 +109,7 @@ import {
updatePlatformDynamicPriority,
updateTenant,
updateUserGroup,
verifySecurityEventConnection,
} from './api';
import type { ConsoleData, StatItem } from './app-state';
import { AppShell } from './components/layout/AppShell';
@ -116,11 +122,13 @@ import {
persistAccessToken,
readStoredAccessToken,
} from './lib/auth-storage';
import { restoreOIDCBrowserSession } from './lib/oidc-browser-session';
import {
reconcileOIDCBrowserSessionAfterRuntimeChange,
restoreOIDCBrowserSession,
} from './lib/oidc-browser-session';
import {
consumeOIDCCallbackError,
oidcBrowserSessionEnabled,
oidcLoginEnabled,
loadOIDCRuntimeConfiguration,
startOIDCLogin,
startOIDCLogout,
} from './lib/oidc';
@ -173,6 +181,7 @@ type DataKey =
| 'clientCustomizationSettings'
| 'fileStorageChannels'
| 'fileStorageSettings'
| 'securityEventConnection'
| 'platforms'
| 'models'
| 'providers'
@ -222,6 +231,7 @@ export function App() {
const [clientCustomizationSettings, setClientCustomizationSettings] = useState<ClientCustomizationSettings | null>(null);
const [fileStorageChannels, setFileStorageChannels] = useState<FileStorageChannel[]>([]);
const [fileStorageSettings, setFileStorageSettings] = useState<FileStorageSettings | null>(null);
const [securityEventConnection, setSecurityEventConnection] = useState<SecurityEventConnectionResponse | null>(null);
const [providers, setProviders] = useState<CatalogProvider[]>([]);
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
const [pricingRules, setPricingRules] = useState<PricingRule[]>([]);
@ -253,6 +263,15 @@ export function App() {
const [state, setState] = useState<LoadState>('idle');
const [error, setError] = useState('');
const [oidcCallbackError, setOIDCCallbackError] = useState(() => consumeOIDCCallbackError());
const [oidcEnabled, setOIDCEnabled] = useState(false);
const currentCredentialRef = useRef(token);
const resetAuthenticatedSessionRef = useRef<() => void>(() => undefined);
currentCredentialRef.current = token;
resetAuthenticatedSessionRef.current = () => {
resetAuthenticatedSession();
setAuthMode('login');
setError('统一认证配置已变更,当前登录会话已失效,请重新登录。');
};
const loadedDataKeysRef = useRef(new Set<DataKey>());
const loadingDataKeysRef = useRef(new Set<DataKey>());
const loadedTaskQueryKeyRef = useRef('');
@ -286,24 +305,48 @@ export function App() {
useEffect(() => {
let cancelled = false;
void (async () => {
if (oidcCallbackError) return;
if (!oidcBrowserSessionEnabled()) return;
if (readStoredAccessToken() || !oidcLoginEnabled()) return;
const restored = await restoreOIDCBrowserSession();
if (!restored || cancelled) return;
const applyRestoredSession = (restored: NonNullable<Awaited<ReturnType<typeof restoreOIDCBrowserSession>>>) => {
if (cancelled) return;
setCurrentUser(restored.user);
loadedDataKeysRef.current.add('currentUser');
setToken(restored.credential);
setState('idle');
setError('');
})().catch((err) => {
};
const loadIdentityRuntime = async (force = false) => {
const configuration = await loadOIDCRuntimeConfiguration(force);
if (cancelled) return;
const identityEnabled = configuration.enabled && configuration.oidcLogin;
setOIDCEnabled(identityEnabled);
if (force && currentCredentialRef.current === OIDC_BROWSER_SESSION_CREDENTIAL) {
await reconcileOIDCBrowserSessionAfterRuntimeChange({
credential: currentCredentialRef.current,
oidcEnabled: identityEnabled,
onReset: () => {
if (!cancelled) resetAuthenticatedSessionRef.current();
},
onRestored: applyRestoredSession,
});
return;
}
if (oidcCallbackError || readStoredAccessToken() || !identityEnabled) return;
const restored = await restoreOIDCBrowserSession();
if (!restored || cancelled) return;
applyRestoredSession(restored);
};
const handleLoadError = (err: unknown) => {
if (cancelled) return;
setState('error');
setError(err instanceof Error ? err.message : '统一认证登录失败');
});
};
const runtimeChanged = () => {
void loadIdentityRuntime(true).catch(handleLoadError);
};
window.addEventListener('identity-runtime-changed', runtimeChanged);
void loadIdentityRuntime().catch(handleLoadError);
return () => {
cancelled = true;
window.removeEventListener('identity-runtime-changed', runtimeChanged);
};
}, [oidcCallbackError]);
useEffect(() => {
@ -383,6 +426,7 @@ export function App() {
modelRateLimits,
modelRateLimitsUpdatedAt,
runtimePolicySets,
securityEventConnection,
taskResult,
tasks,
tenants,
@ -390,7 +434,7 @@ export function App() {
users,
walletAccounts,
walletTransactions,
}), [accessRules, apiKeys, auditLogs, baseModels, clientCustomizationSettings, currentUser, currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]);
}), [accessRules, apiKeys, auditLogs, baseModels, clientCustomizationSettings, currentUser, currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, securityEventConnection, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]);
async function refresh(nextToken = token) {
await ensureRouteData(nextToken, true);
@ -480,6 +524,9 @@ export function App() {
case 'fileStorageSettings':
setFileStorageSettings(await getFileStorageSettings(nextToken));
return;
case 'securityEventConnection':
setSecurityEventConnection(await getSecurityEventConnection(nextToken));
return;
case 'playgroundModels':
setPlaygroundModels((await listPlayableModels(nextToken)).items);
return;
@ -1017,6 +1064,54 @@ export function App() {
}
}
async function connectSecurityEvents(transmitterIssuer: string, managementClientId = '', managementClientSecret = '') {
setCoreState('loading');
setCoreMessage('');
try {
const version = securityEventConnection?.connection?.version;
const connection = await connectSecurityEventTransmitter(token, transmitterIssuer, managementClientId, managementClientSecret, version);
setSecurityEventConnection(connection);
setCoreState('ready');
setCoreMessage('认证中心安全事件连接正在验证。');
} catch (err) {
setCoreState('error');
setCoreMessage(err instanceof Error ? err.message : '连接认证中心失败');
throw err;
}
}
async function refreshSecurityEvents() {
setCoreState('loading');
setCoreMessage('');
try {
const connection = await getSecurityEventConnection(token);
setSecurityEventConnection(connection);
setCoreState('ready');
} catch (err) {
setCoreState('error');
setCoreMessage(err instanceof Error ? err.message : '刷新安全事件连接失败');
throw err;
}
}
async function verifySecurityEvents() {
const connection = securityEventConnection?.connection;
if (!connection) return;
setSecurityEventConnection(await verifySecurityEventConnection(token, connection.version));
}
async function rotateSecurityEventsCredential() {
const connection = securityEventConnection?.connection;
if (!connection) return;
setSecurityEventConnection(await rotateSecurityEventConnectionCredential(token, connection.version));
}
async function disconnectSecurityEvents() {
const connection = securityEventConnection?.connection;
if (!connection) return;
setSecurityEventConnection(await disconnectSecurityEventConnection(token, connection.version));
}
async function removeFileStorageChannel(channelId: string) {
setCoreState('loading');
setCoreMessage('');
@ -1095,6 +1190,7 @@ export function App() {
setPlaygroundModels([]);
setNetworkProxyConfig(null);
setFileStorageChannels([]);
setSecurityEventConnection(null);
setProviders([]);
setBaseModels([]);
setPricingRules([]);
@ -1293,7 +1389,7 @@ export function App() {
onSubmitExternalToken={submitExternalToken}
onSubmitLogin={submitLogin}
onSubmitRegister={submitRegister}
oidcEnabled={oidcLoginEnabled()}
oidcEnabled={oidcEnabled}
onOIDCLogin={loginWithOIDC}
/>
)
@ -1301,6 +1397,7 @@ export function App() {
{activePage === 'admin' && (
isAuthenticated ? (
<AdminPage
token={token}
data={data}
operationMessage={coreMessage}
section={adminSection}
@ -1332,6 +1429,11 @@ export function App() {
onSaveFileStorageChannel={saveFileStorageChannel}
onSaveFileStorageSettings={saveFileStorageSettings}
onSaveClientCustomizationSettings={saveClientCustomizationSettings}
onConnectSecurityEvents={connectSecurityEvents}
onDisconnectSecurityEvents={disconnectSecurityEvents}
onRefreshSecurityEvents={refreshSecurityEvents}
onRotateSecurityEventsCredential={rotateSecurityEventsCredential}
onVerifySecurityEvents={verifySecurityEvents}
onSaveTenant={saveTenant}
onSaveUser={saveUser}
onRechargeUserWalletBalance={rechargeUserWallet}
@ -1353,7 +1455,7 @@ export function App() {
onSubmitExternalToken={submitExternalToken}
onSubmitLogin={submitLogin}
onSubmitRegister={submitRegister}
oidcEnabled={oidcLoginEnabled()}
oidcEnabled={oidcEnabled}
onOIDCLogin={loginWithOIDC}
/>
)
@ -1553,7 +1655,7 @@ function dataKeysForRoute(
case 'accessRules':
return ['accessRules', 'userGroups', 'platforms', 'models'];
case 'systemSettings':
return ['clientCustomizationSettings', 'fileStorageSettings', 'fileStorageChannels'];
return ['clientCustomizationSettings', 'fileStorageSettings', 'fileStorageChannels', 'securityEventConnection'];
default:
return [];
}

View File

@ -1,5 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
cancelIdentityPairing,
connectSecurityEventTransmitter,
createResponse,
deleteOIDCBrowserSession,
GatewayApiError,
@ -8,6 +10,9 @@ import {
getCurrentUser,
getOpsManagementSkillMetadata,
OIDC_BROWSER_SESSION_CREDENTIAL,
startIdentityPairing,
retireIdentityPairingSecurityEventConflict,
validateIdentityRevision,
} from './api';
describe('Gateway provisioning errors', () => {
@ -22,6 +27,10 @@ describe('Gateway provisioning errors', () => {
['OIDC_SESSION_REFRESH_UNAVAILABLE', '认证中心暂时不可用,请稍后重试'],
['OIDC_SESSION_STORE_UNAVAILABLE', '登录会话存储暂时不可用,请稍后重试'],
['OIDC_SESSION_CSRF_REJECTED', '登录会话来源校验失败,请刷新后重试'],
['IDENTITY_PAIRING_IN_PROGRESS', '请先完成或放弃当前统一认证配对'],
['IDENTITY_PAIRING_NOT_CANCELLABLE', '当前统一认证配置不能放弃'],
['IDENTITY_PAIRING_CONFLICT_NOT_RESOLVABLE', '当前配对状态已变化,请刷新后重试'],
['IDENTITY_VERSION_CONFLICT', '统一认证状态已变化,请刷新后重试'],
] as const;
for (const [code, expected] of cases) {
@ -37,6 +46,132 @@ describe('Gateway provisioning errors', () => {
});
});
describe('identity configuration transport', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('keeps the one-time onboarding code in the request body and enforces write headers', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'pairing', version: 1 }), {
status: 202,
headers: { 'Content-Type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
await startIdentityPairing('manager-token', {
authCenterUrl: 'https://auth.example.com',
onboardingCode: 'one-time-code-must-stay-in-body',
publicBaseUrl: 'https://api.gateway.example.com',
webBaseUrl: 'https://gateway.example.com',
localTenantKey: 'default',
legacyJwtEnabled: false,
});
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).not.toContain('one-time-code-must-stay-in-body');
expect(JSON.parse(String(init.body)).onboardingCode).toBe('one-time-code-must-stay-in-body');
expect(new Headers(init.headers).get('If-Match')).toBe('W/"0"');
expect(new Headers(init.headers).get('Idempotency-Key')).toContain('identity-pair-');
});
it('sends the revision version when validating a draft or active runtime', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'revision', version: 8 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
await validateIdentityRevision('manager-token', 'revision', 7);
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"');
expect(init.credentials).toBe('include');
});
it('cancels a pairing with its own ETag and an idempotency key', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
id: 'pairing', status: 'cancelled', cleanupStatus: 'pending', version: 8,
}), {
status: 202,
headers: { 'Content-Type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
await cancelIdentityPairing('manager-token', 'pairing', 7);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toContain('/api/admin/system/identity/pairings/pairing/cancel');
expect(init.method).toBe('POST');
expect(init.body).toBeUndefined();
expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"');
expect(new Headers(init.headers).get('Idempotency-Key')).toContain('identity-cancel-');
expect(init.credentials).toBe('include');
});
it('retires an SSF conflict through the pairing-scoped recovery endpoint', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
id: 'pairing', status: 'credentials_saved', version: 7,
}), {
status: 202,
headers: { 'Content-Type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
await retireIdentityPairingSecurityEventConflict('manager-token', 'pairing', 7);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toContain('/api/admin/system/identity/pairings/pairing/retire-conflicting-security-event');
expect(init.method).toBe('POST');
expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"');
expect(new Headers(init.headers).get('Idempotency-Key')).toContain('identity-retire-ssf-conflict-');
});
});
describe('security event connection transport', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('sends the one-time machine credential only in the connection request', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ connected: true }), {
status: 202,
headers: { 'Content-Type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 'gateway-machine', 'one-time-machine-secret');
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.method).toBe('PUT');
expect(JSON.parse(String(init.body))).toEqual({
transmitter_issuer: 'https://auth.example/ssf',
management_client_id: 'gateway-machine',
management_client_secret: 'one-time-machine-secret',
});
expect(new Headers(init.headers).get('Idempotency-Key')).toContain('ssf-connect-');
expect(new Headers(init.headers).has('If-Match')).toBe(false);
});
it('sends If-Match when retrying an existing failed connection', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ connected: true }), {
status: 202,
headers: { 'Content-Type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 'gateway-machine', 'one-time-machine-secret', 7);
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"');
});
});
describe('OIDC browser session transport', () => {
afterEach(() => {
vi.unstubAllGlobals();

View File

@ -20,6 +20,11 @@ import type {
GatewayAuditLog,
GatewayRunnerPolicy,
GatewayRunnerPolicyUpsertRequest,
IdentityConfigurationRevision,
IdentityConfigurationView,
IdentityPairingExchange,
IdentityPairingInput,
IdentityRevisionPolicyUpdate,
GatewayTenant,
GatewayTenantUpsertRequest,
GatewayNetworkProxyConfig,
@ -44,6 +49,7 @@ import type {
RateLimitWindow,
RuntimePolicySet,
RuntimePolicySetUpsertRequest,
SecurityEventConnectionResponse,
UserGroup,
UserGroupUpsertRequest,
WalletAdjustmentResponse,
@ -52,7 +58,6 @@ import type {
WalletSummaryResponse,
} from '@easyai-ai-gateway/contracts';
import type { PlatformCreateInput, PlatformModelBindingInput, WorkspaceTaskQuery } from './types';
import { oidcBrowserSessionEnabled } from './lib/oidc';
const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088';
export const OIDC_BROWSER_SESSION_CREDENTIAL = '__easyai_gateway_oidc_browser_session__';
@ -668,7 +673,7 @@ export async function* streamChatCompletionText(
...authorizationHeader(token),
'Content-Type': 'application/json',
},
credentials: oidcBrowserSessionEnabled() ? 'include' : 'same-origin',
credentials: 'include',
method: 'POST',
signal,
});
@ -871,7 +876,7 @@ export async function uploadFileToStorage(
const response = await fetch(`${API_BASE}/v1/files/upload`, {
body: form,
credentials: oidcBrowserSessionEnabled() ? 'include' : 'same-origin',
credentials: 'include',
headers: authorizationHeader(token),
method: 'POST',
});
@ -1031,6 +1036,114 @@ export async function updateClientCustomizationSettings(
});
}
const identityConfigurationPath = '/api/admin/system/identity';
export async function getIdentityConfiguration(token: string): Promise<IdentityConfigurationView> {
return request<IdentityConfigurationView>(`${identityConfigurationPath}/configuration`, { token });
}
export async function startIdentityPairing(token: string, input: IdentityPairingInput): Promise<IdentityPairingExchange> {
return identityConfigurationWrite<IdentityPairingExchange>(token, `${identityConfigurationPath}/pairings`, 'POST', 0, input, 'pair');
}
export async function getIdentityPairing(token: string, pairingId: string): Promise<IdentityPairingExchange> {
return request<IdentityPairingExchange>(`${identityConfigurationPath}/pairings/${pairingId}`, { token });
}
export async function cancelIdentityPairing(token: string, pairingId: string, version: number): Promise<IdentityPairingExchange> {
return identityConfigurationWrite<IdentityPairingExchange>(token, `${identityConfigurationPath}/pairings/${pairingId}/cancel`, 'POST', version, undefined, 'cancel');
}
export async function retireIdentityPairingSecurityEventConflict(token: string, pairingId: string, version: number): Promise<IdentityPairingExchange> {
return identityConfigurationWrite<IdentityPairingExchange>(token, `${identityConfigurationPath}/pairings/${pairingId}/retire-conflicting-security-event`, 'POST', version, undefined, 'retire-ssf-conflict');
}
export async function updateIdentityRevisionPolicy(
token: string,
revisionId: string,
version: number,
input: IdentityRevisionPolicyUpdate,
): Promise<IdentityConfigurationRevision> {
return identityConfigurationWrite<IdentityConfigurationRevision>(token, `${identityConfigurationPath}/revisions/${revisionId}`, 'PATCH', version, input, 'policy');
}
export async function validateIdentityRevision(token: string, revisionId: string, version: number): Promise<IdentityConfigurationRevision> {
return identityConfigurationWrite<IdentityConfigurationRevision>(token, `${identityConfigurationPath}/revisions/${revisionId}/validate`, 'POST', version, undefined, 'validate');
}
export async function activateIdentityRevision(token: string, revisionId: string, version: number): Promise<IdentityConfigurationRevision> {
return identityConfigurationWrite<IdentityConfigurationRevision>(token, `${identityConfigurationPath}/revisions/${revisionId}/activate`, 'POST', version, undefined, 'activate');
}
export async function disableIdentityConfiguration(token: string, version: number): Promise<IdentityConfigurationRevision> {
return identityConfigurationWrite<IdentityConfigurationRevision>(token, `${identityConfigurationPath}/disable`, 'POST', version, undefined, 'disable');
}
function identityConfigurationWrite<T>(
token: string,
path: string,
method: string,
version: number,
body: unknown,
action: string,
): Promise<T> {
return request<T>(path, {
body,
method,
token,
headers: {
'Idempotency-Key': `identity-${action}-${crypto.randomUUID()}`,
'If-Match': `W/"${version}"`,
},
});
}
const securityEventConnectionPath = '/api/admin/system/identity/security-events/connection';
export async function getSecurityEventConnection(token: string): Promise<SecurityEventConnectionResponse> {
return request<SecurityEventConnectionResponse>(securityEventConnectionPath, { token });
}
export async function connectSecurityEventTransmitter(
token: string,
transmitterIssuer: string,
managementClientId: string,
managementClientSecret: string,
version?: number,
): Promise<SecurityEventConnectionResponse> {
const headers: Record<string, string> = { 'Idempotency-Key': `ssf-connect-${crypto.randomUUID()}` };
if (version !== undefined) headers['If-Match'] = `W/"${version}"`;
return request<SecurityEventConnectionResponse>(securityEventConnectionPath, {
body: {
transmitter_issuer: transmitterIssuer,
management_client_id: managementClientId,
management_client_secret: managementClientSecret,
}, method: 'PUT', token,
headers,
});
}
export async function verifySecurityEventConnection(token: string, version: number): Promise<SecurityEventConnectionResponse> {
return securityEventConnectionWrite(token, '/verify', version, 'POST', 'verify');
}
export async function rotateSecurityEventConnectionCredential(token: string, version: number): Promise<SecurityEventConnectionResponse> {
return securityEventConnectionWrite(token, '/rotate-credential', version, 'POST', 'rotate');
}
export async function disconnectSecurityEventConnection(token: string, version: number): Promise<SecurityEventConnectionResponse> {
return securityEventConnectionWrite(token, '', version, 'DELETE', 'disconnect');
}
function securityEventConnectionWrite(token: string, suffix: string, version: number, method: string, action: string) {
return request<SecurityEventConnectionResponse>(securityEventConnectionPath + suffix, {
method, token, headers: {
'Idempotency-Key': `ssf-${action}-${crypto.randomUUID()}`,
'If-Match': `W/"${version}"`,
},
});
}
export async function getPublicClientCustomization(): Promise<ClientCustomizationSettings> {
return request<ClientCustomizationSettings>('/api/v1/public/client-customization', { auth: false });
}
@ -1080,7 +1193,7 @@ async function request<T>(
method: options.method ?? 'GET',
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
credentials: oidcBrowserSessionEnabled() ? 'include' : 'same-origin',
credentials: 'include',
});
if (!response.ok) {
const body = await response.text();
@ -1189,6 +1302,10 @@ const gatewayProvisioningErrorMessages: Record<string, string> = {
OIDC_SESSION_REFRESH_UNAVAILABLE: '认证中心暂时不可用,请稍后重试',
OIDC_SESSION_STORE_UNAVAILABLE: '登录会话存储暂时不可用,请稍后重试',
OIDC_SESSION_CSRF_REJECTED: '登录会话来源校验失败,请刷新后重试',
IDENTITY_PAIRING_IN_PROGRESS: '请先完成或放弃当前统一认证配对',
IDENTITY_PAIRING_NOT_CANCELLABLE: '当前统一认证配置不能放弃',
IDENTITY_PAIRING_CONFLICT_NOT_RESOLVABLE: '当前配对状态已变化,请刷新后重试',
IDENTITY_VERSION_CONFLICT: '统一认证状态已变化,请刷新后重试',
};
export function gatewayErrorMessage(details: GatewayErrorDetails) {

View File

@ -23,6 +23,7 @@ import type {
PricingRuleSet,
RateLimitWindow,
RuntimePolicySet,
SecurityEventConnectionResponse,
UserGroup,
} from '@easyai-ai-gateway/contracts';
@ -48,6 +49,7 @@ export interface ConsoleData {
modelRateLimits: ModelRateLimitStatus[];
modelRateLimitsUpdatedAt: number | null;
runtimePolicySets: RuntimePolicySet[];
securityEventConnection: SecurityEventConnectionResponse | null;
taskResult: GatewayTask | null;
tasks: GatewayTask[];
tenants: GatewayTenant[];

View File

@ -0,0 +1,29 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { ConfirmDialog } from './confirm-dialog';
describe('ConfirmDialog', () => {
it('associates its title and destructive-action description', () => {
const html = renderToStaticMarkup(
<ConfirmDialog open title="放弃当前配对?" description="将清理临时资源" onCancel={() => undefined} onConfirm={() => undefined} />,
);
const labelledBy = html.match(/aria-labelledby="([^"]+)"/)?.[1];
const describedBy = html.match(/aria-describedby="([^"]+)"/)?.[1];
expect(labelledBy).toBeTruthy();
expect(describedBy).toBeTruthy();
expect(html).toContain(`id="${labelledBy}"`);
expect(html).toContain(`id="${describedBy}"`);
expect(html).toContain('tabindex="-1"');
});
it('keeps the modal container focusable while its actions are loading', () => {
const html = renderToStaticMarkup(
<ConfirmDialog open loading title="安全退役?" onCancel={() => undefined} onConfirm={() => undefined} />,
);
expect(html).toContain('aria-busy="true"');
expect(html).toContain('tabindex="-1"');
expect(html.match(/disabled=""/g)?.length).toBe(2);
});
});

View File

@ -18,32 +18,79 @@ export interface ConfirmDialogProps {
}
export function ConfirmDialog(props: ConfirmDialogProps) {
const { onCancel, open } = props;
const { open } = props;
const titleId = React.useId();
const descriptionId = React.useId();
const dialogRef = React.useRef<HTMLElement>(null);
const cancelRef = React.useRef<HTMLButtonElement>(null);
const loadingRef = React.useRef(props.loading);
const onCancelRef = React.useRef(props.onCancel);
loadingRef.current = props.loading;
onCancelRef.current = props.onCancel;
React.useEffect(() => {
if (!open) return undefined;
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const focusFrame = window.requestAnimationFrame(() => {
if (cancelRef.current && !cancelRef.current.disabled) cancelRef.current.focus();
else dialogRef.current?.focus();
});
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onCancel();
if (event.key === 'Escape' && !loadingRef.current) {
event.preventDefault();
onCancelRef.current();
return;
}
if (event.key !== 'Tab' || !dialogRef.current) return;
const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'));
if (!focusable.length) {
event.preventDefault();
dialogRef.current.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (document.activeElement === dialogRef.current) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
} else if (!dialogRef.current.contains(document.activeElement)) {
event.preventDefault();
first.focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [onCancel, open]);
return () => {
window.cancelAnimationFrame(focusFrame);
window.removeEventListener('keydown', onKeyDown);
previouslyFocused?.focus();
};
}, [open]);
React.useEffect(() => {
if (open && props.loading) dialogRef.current?.focus();
}, [open, props.loading]);
if (!open) return null;
return (
<div className="confirmDialogBackdrop" role="presentation">
<section className={cn('confirmDialog', props.className)} role="alertdialog" aria-modal="true" aria-labelledby="confirm-dialog-title">
<div className="confirmDialogIcon">
<section ref={dialogRef} className={cn('confirmDialog', props.className)} role="alertdialog" tabIndex={-1} aria-busy={props.loading || undefined} aria-modal="true" aria-labelledby={titleId} aria-describedby={props.description ? descriptionId : undefined}>
<div className="confirmDialogIcon" aria-hidden="true">
<AlertTriangle size={18} />
</div>
<div className="confirmDialogBody">
<strong id="confirm-dialog-title">{props.title}</strong>
{props.description && <p>{props.description}</p>}
<strong id={titleId}>{props.title}</strong>
{props.description && <p id={descriptionId}>{props.description}</p>}
{props.children}
</div>
<footer className="confirmDialogActions">
<Button type="button" variant="outline" size="sm" disabled={props.loading} onClick={props.onCancel}>
<Button ref={cancelRef} type="button" variant="outline" size="sm" disabled={props.loading} onClick={props.onCancel}>
{props.cancelLabel ?? '取消'}
</Button>
<Button

View File

@ -1,6 +1,9 @@
import { describe, expect, it, vi } from 'vitest';
import { OIDC_BROWSER_SESSION_CREDENTIAL } from '../api';
import { restoreOIDCBrowserSession } from './oidc-browser-session';
import {
reconcileOIDCBrowserSessionAfterRuntimeChange,
restoreOIDCBrowserSession,
} from './oidc-browser-session';
describe('OIDC browser session lifecycle', () => {
it('restores a shared cookie session in a fresh tab', async () => {
@ -21,4 +24,42 @@ describe('OIDC browser session lifecycle', () => {
await expect(restoreOIDCBrowserSession()).resolves.toBeNull();
});
it('resets App authentication when an OIDC sentinel session re-probe returns 401', async () => {
const resetAuthenticatedSession = vi.fn();
const onRestored = vi.fn();
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
error: { message: 'unauthorized', status: 401 },
}), { status: 401, headers: { 'Content-Type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
await expect(reconcileOIDCBrowserSessionAfterRuntimeChange({
credential: OIDC_BROWSER_SESSION_CREDENTIAL,
oidcEnabled: true,
onReset: resetAuthenticatedSession,
onRestored,
})).resolves.toBe('reset');
expect(fetchMock).toHaveBeenCalledOnce();
expect(resetAuthenticatedSession).toHaveBeenCalledOnce();
expect(onRestored).not.toHaveBeenCalled();
});
it('resets App authentication without probing /me when identity is disabled', async () => {
const resetAuthenticatedSession = vi.fn();
const onRestored = vi.fn();
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(reconcileOIDCBrowserSessionAfterRuntimeChange({
credential: OIDC_BROWSER_SESSION_CREDENTIAL,
oidcEnabled: false,
onReset: resetAuthenticatedSession,
onRestored,
})).resolves.toBe('reset');
expect(fetchMock).not.toHaveBeenCalled();
expect(resetAuthenticatedSession).toHaveBeenCalledOnce();
expect(onRestored).not.toHaveBeenCalled();
});
});

View File

@ -11,6 +11,13 @@ export interface RestoredOIDCBrowserSession {
user: CurrentUser;
}
export interface ReconcileOIDCBrowserSessionOptions {
credential: string;
oidcEnabled: boolean;
onReset: () => void;
onRestored: (session: RestoredOIDCBrowserSession) => void;
}
export async function restoreOIDCBrowserSession(): Promise<RestoredOIDCBrowserSession | null> {
try {
const user = await getCurrentUser(OIDC_BROWSER_SESSION_CREDENTIAL);
@ -20,3 +27,20 @@ export async function restoreOIDCBrowserSession(): Promise<RestoredOIDCBrowserSe
throw error;
}
}
export async function reconcileOIDCBrowserSessionAfterRuntimeChange(
options: ReconcileOIDCBrowserSessionOptions,
): Promise<'ignored' | 'reset' | 'restored'> {
if (options.credential !== OIDC_BROWSER_SESSION_CREDENTIAL) return 'ignored';
if (!options.oidcEnabled) {
options.onReset();
return 'reset';
}
const restored = await restoreOIDCBrowserSession();
if (!restored) {
options.onReset();
return 'reset';
}
options.onRestored(restored);
return 'restored';
}

View File

@ -8,8 +8,11 @@ describe('OIDC BFF navigation', () => {
});
it('sends only returnTo to the Gateway login endpoint', async () => {
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ enabled: true, oidcLogin: true, loginUrl: '/api/v1/auth/oidc/login', logoutUrl: '/api/v1/auth/oidc/logout', status: 'active' }),
}));
const assign = vi.fn();
vi.stubGlobal('window', {
location: { pathname: '/workspace', search: '?tab=wallet', hash: '#balance', assign },
@ -24,8 +27,11 @@ describe('OIDC BFF navigation', () => {
});
it('submits logout as a top-level POST without exposing tokens', async () => {
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ enabled: true, oidcLogin: true, loginUrl: '/api/v1/auth/oidc/login', logoutUrl: '/api/v1/auth/oidc/logout', status: 'active' }),
}));
const submit = vi.fn();
const form = { method: '', action: '', style: { display: '' }, submit };
const appendChild = vi.fn();
@ -39,7 +45,6 @@ describe('OIDC BFF navigation', () => {
});
it('turns an invalid login transaction callback into an explicit retry action', async () => {
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
const replaceState = vi.fn();
vi.stubGlobal('window', {
location: { pathname: '/', search: '?oidcError=OIDC_LOGIN_INVALID', hash: '#top' },
@ -56,7 +61,6 @@ describe('OIDC BFF navigation', () => {
});
it('explains a missing transaction cookie without retaining diagnostic query parameters', async () => {
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
const replaceState = vi.fn();
vi.stubGlobal('window', {
location: {
@ -79,7 +83,6 @@ describe('OIDC BFF navigation', () => {
});
it('does not offer transaction retry for a token exchange failure', async () => {
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
vi.stubGlobal('window', {
location: { pathname: '/', search: '?oidcError=OIDC_TOKEN_EXCHANGE_FAILED', hash: '' },
history: { replaceState: vi.fn() },

View File

@ -1,7 +1,18 @@
const enabled = import.meta.env.VITE_OIDC_ENABLED === 'true';
const browserSessionEnabled = import.meta.env.VITE_OIDC_BROWSER_SESSION_ENABLED !== 'false';
const gatewayAPIBase = (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088').replace(/\/$/, '');
export type OIDCRuntimeConfiguration = {
enabled: boolean;
oidcLogin: boolean;
loginUrl?: string;
logoutUrl?: string;
status: string;
};
const disabledRuntime: OIDCRuntimeConfiguration = { enabled: false, oidcLogin: false, status: 'disabled' };
let runtimeConfiguration = disabledRuntime;
let runtimeLoaded = false;
let runtimeRequest: Promise<OIDCRuntimeConfiguration> | null = null;
export type OIDCCallbackError = {
code: string;
reason?: string;
@ -27,32 +38,67 @@ const loginTransactionFailureMessages: Record<string, string> = {
};
export function oidcLoginEnabled() {
return enabled;
return runtimeConfiguration.enabled && runtimeConfiguration.oidcLogin;
}
export function oidcBrowserSessionEnabled() {
return browserSessionEnabled;
return oidcLoginEnabled();
}
export async function loadOIDCRuntimeConfiguration(force = false): Promise<OIDCRuntimeConfiguration> {
if (!force && runtimeLoaded) return runtimeConfiguration;
if (!force && runtimeRequest) return runtimeRequest;
runtimeRequest = fetch(`${gatewayAPIBase}/api/v1/public/identity`, {
credentials: 'include',
headers: { Accept: 'application/json' },
}).then(async (response) => {
if (!response.ok) return disabledRuntime;
const value = await response.json() as Partial<OIDCRuntimeConfiguration>;
if (typeof value.enabled !== 'boolean' || typeof value.oidcLogin !== 'boolean' || typeof value.status !== 'string') {
return disabledRuntime;
}
return {
enabled: value.enabled,
oidcLogin: value.oidcLogin,
status: value.status,
...(typeof value.loginUrl === 'string' ? { loginUrl: value.loginUrl } : {}),
...(typeof value.logoutUrl === 'string' ? { logoutUrl: value.logoutUrl } : {}),
};
}).catch(() => disabledRuntime).then((configuration) => {
runtimeConfiguration = configuration;
runtimeLoaded = true;
runtimeRequest = null;
return configuration;
});
return runtimeRequest;
}
export async function startOIDCLogin() {
if (!oidcLoginEnabled()) throw new Error('统一认证未配置');
const configuration = await loadOIDCRuntimeConfiguration(true);
if (!configuration.enabled || !configuration.oidcLogin || !configuration.loginUrl) throw new Error('统一认证未配置');
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/';
const loginURL = new URL(`${gatewayAPIBase}/api/v1/auth/oidc/login`);
const loginURL = new URL(identityEndpointURL(configuration.loginUrl));
loginURL.searchParams.set('returnTo', returnTo.startsWith('/') ? returnTo : '/');
window.location.assign(loginURL.toString());
}
export async function startOIDCLogout() {
if (!oidcLoginEnabled()) return false;
const configuration = await loadOIDCRuntimeConfiguration(true);
if (!configuration.enabled || !configuration.oidcLogin || !configuration.logoutUrl) return false;
const form = document.createElement('form');
form.method = 'POST';
form.action = `${gatewayAPIBase}/api/v1/auth/oidc/logout`;
form.action = identityEndpointURL(configuration.logoutUrl);
form.style.display = 'none';
document.body.appendChild(form);
form.submit();
return true;
}
function identityEndpointURL(path: string) {
if (/^https:\/\//i.test(path) || /^http:\/\/(localhost|127\.0\.0\.1)(:|\/)/i.test(path)) return path;
return `${gatewayAPIBase}/${path.replace(/^\//, '')}`;
}
export function consumeOIDCCallbackError() {
const params = new URLSearchParams(window.location.search);
const code = params.get('oidcError') ?? '';

View File

@ -51,6 +51,7 @@ const tabs = [
] satisfies Array<{ value: AdminSection; label: string; icon: ReactNode }>;
export function AdminPage(props: {
token: string;
data: ConsoleData;
operationMessage: string;
section: AdminSection;
@ -82,6 +83,11 @@ export function AdminPage(props: {
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
onConnectSecurityEvents: (transmitterIssuer: string) => Promise<void>;
onDisconnectSecurityEvents: () => Promise<void>;
onRefreshSecurityEvents: () => Promise<void>;
onRotateSecurityEventsCredential: () => Promise<void>;
onVerifySecurityEvents: () => Promise<void>;
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
@ -186,6 +192,7 @@ export function AdminPage(props: {
{props.section === 'auditLogs' && <AuditLogsPanel auditLogs={props.data.auditLogs} message={props.operationMessage} />}
{props.section === 'systemSettings' && (
<SystemSettingsPanel
token={props.token}
channels={props.data.fileStorageChannels}
clientCustomizationSettings={props.data.clientCustomizationSettings}
settings={props.data.fileStorageSettings}

View File

@ -1,10 +1,11 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Database, Pencil, Plus, RotateCcw, Save, ServerCog, Settings2, Trash2 } from 'lucide-react';
import { Database, Pencil, Plus, RotateCcw, Save, ServerCog, Settings2, ShieldCheck, Trash2 } from 'lucide-react';
import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select, Tabs, Textarea } from '../../components/ui';
import type { LoadState } from '../../types';
import { UnifiedIdentityPanel } from './UnifiedIdentityPanel';
type SystemSettingsTab = 'fileStorage' | 'clientCustomization';
type SystemSettingsTab = 'fileStorage' | 'clientCustomization' | 'identity';
type ClientCustomizationForm = {
clientEnglishName: string;
@ -54,6 +55,7 @@ const resultUploadPolicyOptions = [
];
export function SystemSettingsPanel(props: {
token: string;
channels: FileStorageChannel[];
clientCustomizationSettings: ClientCustomizationSettings | null;
message: string;
@ -162,6 +164,7 @@ export function SystemSettingsPanel(props: {
tabs={[
{ value: 'fileStorage', label: '文件存储', icon: <Database size={15} /> },
{ value: 'clientCustomization', label: '客户端自定义', icon: <Settings2 size={15} /> },
{ value: 'identity', label: '统一认证', icon: <ShieldCheck size={15} /> },
]}
onValueChange={setActiveTab}
/>
@ -275,6 +278,8 @@ export function SystemSettingsPanel(props: {
</section>
)}
{activeTab === 'identity' && <UnifiedIdentityPanel token={props.token} />}
<FormDialog
ariaLabel={editingChannel ? '编辑文件存储渠道' : '新增文件存储渠道'}
bodyClassName="fileStorageDialogBody"

View File

@ -0,0 +1,214 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import type { IdentityConfigurationRevision } from '@easyai-ai-gateway/contracts';
import { GatewayApiError } from '../../api';
import { UnifiedIdentityPanel } from './UnifiedIdentityPanel';
import {
IdentityRuntimeActions,
PairingProgressCard,
executeIdentityOperation,
isStaleIdentityOperation,
pairingFailureMessage,
} from './UnifiedIdentityPanel';
describe('UnifiedIdentityPanel', () => {
it('asks only for the standard pairing inputs and does not expose a machine secret field', () => {
const html = renderToStaticMarkup(<UnifiedIdentityPanel token="manager-token" />);
expect(html).toContain('Auth Center 地址');
expect(html).toContain('一次性接入码');
expect(html).toContain('Gateway API 公网地址');
expect(html).not.toContain('value="/gateway-api"');
expect(html).toContain('Gateway Web 地址');
expect(html).toContain('Gateway 本地租户映射');
expect(html).not.toContain('Machine Client Secret');
expect(html).not.toContain('SSF Transmitter Issuer');
});
});
describe('PairingProgressCard', () => {
it('shows a safe actionable discovery failure and a recovery action', () => {
const html = renderToStaticMarkup(<PairingProgressCard loading={false} onCancel={() => undefined} pairing={{
id: 'pairing', revisionId: 'revision', remoteExchangeId: 'exchange', status: 'credentials_saved',
cleanupStatus: 'none', remoteVersion: 4, expiresAt: '2026-07-17T15:00:00+08:00', version: 7,
lastErrorCategory: 'security_event_discovery_failed', createdAt: '2026-07-17T14:00:00+08:00', updatedAt: '2026-07-17T14:01:00+08:00',
}} />);
expect(html).toContain('无法获取或校验 SSF Discovery');
expect(html).toContain('后台正在自动重试');
expect(html).toContain('放弃并清理本次配对');
expect(html).toContain('role="status"');
expect(html).toContain('aria-busy="true"');
});
it('does not reflect an untrusted category into the page', () => {
expect(pairingFailureMessage('token-value <script>alert(1)</script>')).toBe('统一认证配对步骤暂时失败');
expect(pairingFailureMessage('secret_token_abcdef')).toBe('统一认证配对步骤暂时失败');
});
it('renders cancellation cleanup as resumable progress without another cancel button', () => {
const html = renderToStaticMarkup(<PairingProgressCard loading={false} onCancel={() => undefined} pairing={{
id: 'pairing', revisionId: 'revision', remoteExchangeId: 'exchange', status: 'cancelled',
cleanupStatus: 'pending', remoteVersion: 4, expiresAt: '2026-07-17T15:00:00+08:00', version: 8,
lastErrorCategory: 'cleanup_security_event_retirement_pending', createdAt: '2026-07-17T14:00:00+08:00', updatedAt: '2026-07-17T14:01:00+08:00',
}} />);
expect(html).toContain('正在清理已放弃的配对');
expect(html).toContain('安全退役窗口');
expect(html).not.toContain('放弃并清理本次配对');
});
it('marks an existing SSF owner conflict as action-required', () => {
const html = renderToStaticMarkup(<PairingProgressCard loading={false} onCancel={() => undefined} onResolveConnectionConflict={() => undefined} pairing={{
id: 'pairing', revisionId: 'revision', remoteExchangeId: 'exchange', status: 'credentials_saved',
cleanupStatus: 'none', remoteVersion: 4, expiresAt: '2026-07-17T15:00:00+08:00', version: 7,
lastErrorCategory: 'security_event_connection_conflict', createdAt: '2026-07-17T14:00:00+08:00', updatedAt: '2026-07-17T14:01:00+08:00',
}} />);
expect(html).toContain('不会通过自动重试恢复');
expect(html).toContain('安全退役现有连接');
});
it('does not claim an unsafe credential handoff will recover automatically', () => {
const html = renderToStaticMarkup(<PairingProgressCard loading={false} onCancel={() => undefined} pairing={{
id: 'pairing', revisionId: 'revision', remoteExchangeId: 'exchange', status: 'credentials_saved',
cleanupStatus: 'none', remoteVersion: 4, expiresAt: '2026-07-17T15:00:00+08:00', version: 7,
lastErrorCategory: 'security_event_credential_handoff_unsafe', createdAt: '2026-07-17T14:00:00+08:00', updatedAt: '2026-07-17T14:01:00+08:00',
}} />);
expect(html).toContain('不会自动恢复');
expect(html).toContain('原配置下断开旧连接');
expect(html).not.toContain('后台正在自动重试');
expect(html).toContain('放弃并清理本次配对');
});
it('shows safe retirement as automatic progress without another retirement action', () => {
const html = renderToStaticMarkup(<PairingProgressCard loading={false} onCancel={() => undefined} onResolveConnectionConflict={() => undefined} pairing={{
id: 'pairing', revisionId: 'revision', remoteExchangeId: 'exchange', status: 'credentials_saved',
cleanupStatus: 'none', remoteVersion: 4, expiresAt: '2026-07-17T15:00:00+08:00', version: 7,
lastErrorCategory: 'security_event_retirement_pending', createdAt: '2026-07-17T14:00:00+08:00', updatedAt: '2026-07-17T14:01:00+08:00',
}} />);
expect(html).toContain('正在安全退役,完成后本次配对会自动继续');
expect(html).not.toContain('安全退役现有连接</button>');
});
});
describe('identity confirmation freshness', () => {
it('requires a fresh confirmation after conflict responses', () => {
expect(isStaleIdentityOperation(new GatewayApiError({ message: 'conflict', status: 409 }))).toBe(true);
expect(isStaleIdentityOperation(new GatewayApiError({ message: 'changed', status: 412 }))).toBe(true);
expect(isStaleIdentityOperation(new GatewayApiError({ message: 'retry', status: 503 }))).toBe(false);
});
});
describe('identity runtime operation completion', () => {
it('treats a successful disable followed by refresh 401 as submitted and requests re-login', async () => {
const onSuccess = vi.fn();
const onRuntimeChanged = vi.fn();
await expect(executeIdentityOperation({
action: vi.fn().mockResolvedValue(undefined),
onRuntimeChanged,
onSuccess,
refresh: vi.fn().mockRejectedValue(new GatewayApiError({ message: 'unauthorized', status: 401 })),
runtimeChanged: true,
success: '统一认证已禁用,本地管理登录继续可用。',
})).resolves.toBe(true);
expect(onSuccess).toHaveBeenCalledWith(expect.stringContaining('请重新登录'));
expect(onRuntimeChanged).toHaveBeenCalledOnce();
});
it('treats a successful activation followed by refresh 401 as submitted and requests re-login', async () => {
const onSuccess = vi.fn();
const onRuntimeChanged = vi.fn();
await expect(executeIdentityOperation({
action: vi.fn().mockResolvedValue(undefined),
onRuntimeChanged,
onSuccess,
refresh: vi.fn().mockRejectedValue(new GatewayApiError({ message: 'unauthorized', status: 401 })),
runtimeChanged: true,
success: '统一认证已激活,用户需要重新登录。',
})).resolves.toBe(true);
expect(onSuccess).toHaveBeenCalledWith(expect.stringContaining('请重新登录'));
expect(onRuntimeChanged).toHaveBeenCalledOnce();
});
it('does not reverse a committed runtime change when the follow-up refresh is unavailable', async () => {
const onSuccess = vi.fn();
const onRuntimeChanged = vi.fn();
await expect(executeIdentityOperation({
action: vi.fn().mockResolvedValue(undefined),
onRuntimeChanged,
onSuccess,
refresh: vi.fn().mockRejectedValue(new GatewayApiError({ message: 'unavailable', status: 503 })),
runtimeChanged: true,
success: '统一认证已热切换启用,无需重启。',
})).resolves.toBe(true);
expect(onSuccess).toHaveBeenCalledWith(expect.stringContaining('操作已提交'));
expect(onSuccess).toHaveBeenCalledWith(expect.stringContaining('状态刷新失败'));
expect(onSuccess).not.toHaveBeenCalledWith(expect.stringContaining('请重新登录'));
expect(onRuntimeChanged).toHaveBeenCalledOnce();
});
});
describe('IdentityRuntimeActions remote-resource safety', () => {
it('hides active re-pairing for every active configuration', () => {
const html = renderToStaticMarkup(<IdentityRuntimeActions
loading={false}
previous={null}
onDisable={() => undefined}
onValidate={() => undefined}
/>);
expect(html).not.toContain('重新配对');
expect(html).toContain('请先禁用统一认证');
expect(html).toContain('OAuth/SSF 资源');
expect(html).toContain('重新验证');
expect(html).toContain('禁用统一认证');
});
it('replaces unsafe previous-version rollback with a new onboarding-code instruction', () => {
const html = renderToStaticMarkup(<IdentityRuntimeActions
loading={false}
previous={identityRevision('superseded', ['session_revocation'])}
onDisable={() => undefined}
onValidate={() => undefined}
/>);
expect(html).not.toContain('回滚上一版本');
expect(html).toContain('不能直接回滚');
expect(html).toContain('新的接入码');
});
});
function identityRevision(
state: IdentityConfigurationRevision['state'],
capabilities: string[],
): IdentityConfigurationRevision {
return {
id: `${state}-revision`,
state,
schemaVersion: 1,
authCenterUrl: 'https://auth.example.com',
issuer: 'https://issuer.example.com',
scopes: ['openid'],
capabilities,
rolePrefix: 'gateway.',
localTenantKey: 'default',
publicBaseUrl: 'https://api.gateway.example.com',
webBaseUrl: 'https://gateway.example.com',
jitEnabled: true,
legacyJwtEnabled: false,
tokenIntrospection: capabilities.includes('token_introspection'),
sessionRevocation: capabilities.includes('session_revocation'),
sessionIdleSeconds: 1800,
sessionAbsoluteSeconds: 28800,
sessionRefreshSeconds: 60,
version: 1,
createdAt: '2026-07-17T14:00:00+08:00',
updatedAt: '2026-07-17T14:00:00+08:00',
};
}

View File

@ -0,0 +1,488 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { CheckCircle2, Link2, RefreshCw, ShieldCheck, Unplug } from 'lucide-react';
import type {
IdentityConfigurationRevision,
IdentityConfigurationView,
IdentityPairingInput,
} from '@easyai-ai-gateway/contracts';
import {
activateIdentityRevision,
cancelIdentityPairing,
disableIdentityConfiguration,
GatewayApiError,
getIdentityConfiguration,
retireIdentityPairingSecurityEventConflict,
startIdentityPairing,
updateIdentityRevisionPolicy,
validateIdentityRevision,
} from '../../api';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, Input, Label } from '../../components/ui';
const terminalPairingStatuses = new Set(['completed', 'failed', 'expired', 'cancelled']);
export function UnifiedIdentityPanel(props: { token: string }) {
const [configuration, setConfiguration] = useState<IdentityConfigurationView | null>(null);
const [form, setForm] = useState<IdentityPairingInput>(defaultPairingInput);
const [showPairingForm, setShowPairingForm] = useState(false);
const [localTenantKey, setLocalTenantKey] = useState('default');
const [legacyJwtEnabled, setLegacyJwtEnabled] = useState(false);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [pollError, setPollError] = useState('');
const [confirmAction, setConfirmAction] = useState<'disable' | 'cancelPairing' | 'retireSecurityEvent' | null>(null);
const operationInProgress = useRef(false);
async function refresh() {
const next = await getIdentityConfiguration(props.token);
setConfiguration(next);
setPollError('');
if (next.draft) {
setLocalTenantKey(next.draft.localTenantKey);
setLegacyJwtEnabled(next.draft.legacyJwtEnabled);
}
return next;
}
useEffect(() => {
void refresh().catch((caught) => setError(errorMessage(caught, '统一认证配置加载失败')));
}, [props.token]);
useEffect(() => {
const status = configuration?.pairing?.status;
const cleanupPending = status === 'cancelled' && configuration?.pairing?.cleanupStatus === 'pending';
if (!status || (terminalPairingStatuses.has(status) && !cleanupPending)) return undefined;
const timer = window.setInterval(() => {
void refresh().catch(() => setPollError('统一认证状态自动刷新失败,请点击“刷新”重试。'));
}, 2000);
return () => window.clearInterval(timer);
}, [configuration?.pairing?.cleanupStatus, configuration?.pairing?.status, props.token]);
async function run(action: () => Promise<unknown>, success: string, runtimeChanged = false) {
if (operationInProgress.current) return false;
operationInProgress.current = true;
setLoading(true);
setError('');
setMessage('');
try {
return await executeIdentityOperation({
action,
onRuntimeChanged: () => window.dispatchEvent(new Event('identity-runtime-changed')),
onSuccess: setMessage,
refresh,
runtimeChanged,
success,
});
} catch (caught) {
try {
await refresh();
} catch {
// Preserve the original operation error; the manual refresh remains available.
}
setError(errorMessage(caught, '统一认证操作失败'));
if (isStaleIdentityOperation(caught)) setConfirmAction(null);
return false;
} finally {
operationInProgress.current = false;
setLoading(false);
}
}
async function submitPairing(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const input = { ...form };
setForm((current) => ({ ...current, onboardingCode: '' }));
await run(async () => {
await startIdentityPairing(props.token, input);
setShowPairingForm(false);
}, '接入码已领取,正在由 Gateway 后端准备并保存配置。');
}
async function saveDraftPolicy() {
const draft = configuration?.draft;
if (!draft) return;
await run(() => updateIdentityRevisionPolicy(props.token, draft.id, draft.version, {
localTenantKey: localTenantKey.trim(),
legacyJwtEnabled,
}), 'Gateway 本地认证策略已保存。');
}
async function retireConflictingSecurityEventConnection() {
const pairing = configuration?.pairing;
if (!pairing) return false;
return run(async () => {
await retireIdentityPairingSecurityEventConflict(props.token, pairing.id, pairing.version);
}, '现有安全事件连接已进入安全退役;退役完成后本次配对会自动继续。');
}
const active = configuration?.active;
const draft = configuration?.draft;
const previous = configuration?.previous;
const pairing = configuration?.pairing;
const runtime = configuration?.runtime;
const cleanupPending = pairing?.status === 'cancelled' && pairing.cleanupStatus === 'pending';
const cleanupCompleted = pairing?.status === 'cancelled' && pairing.cleanupStatus === 'completed';
const shouldShowPairing = !active && (showPairingForm || !pairing || cleanupCompleted);
return (
<section className="pageStack">
<div className="fileStorageToolbar">
<div>
<strong></strong>
<span>使Gateway OIDCIntrospection SSF</span>
</div>
<div className="fileStorageToolbar">
<Badge variant={active ? 'success' : 'outline'}>{active ? '已启用' : '未启用'}</Badge>
<Button type="button" variant="outline" size="sm" disabled={loading} onClick={() => {
setError('');
void refresh().catch((caught) => setError(errorMessage(caught, '统一认证配置刷新失败')));
}}><RefreshCw size={14} /></Button>
</div>
</div>
{(message || error || pollError) && <div className={`formMessage${error || pollError ? ' error' : ''}`} role={error || pollError ? 'alert' : 'status'} aria-live="polite">{error || pollError || message}</div>}
{active && runtime && (
<Card>
<CardHeader>
<div>
<CardTitle></CardTitle>
<p className="mutedText">Revision {shortID(active.id)} · {active.issuer}</p>
</div>
<Badge variant="success"><CheckCircle2 size={13} />Active</Badge>
</CardHeader>
<CardContent className="pageStack">
<div className="identityHealthGrid">
<IdentityHealth label="OIDC 登录" value={runtime.login} />
<IdentityHealth label="JIT 用户" value={runtime.jit} />
<IdentityHealth label="Token Introspection" value={runtime.tokenIntrospection} />
<IdentityHealth label="SSF 会话撤销" value={runtime.sessionRevocation} />
</div>
<RevisionDetails revision={active} />
<IdentityRuntimeActions
loading={loading}
previous={previous ?? null}
onDisable={() => setConfirmAction('disable')}
onValidate={() => void run(() => validateIdentityRevision(props.token, active.id, active.version), '当前配置重新验证成功。', true)}
/>
</CardContent>
</Card>
)}
{pairing && (!terminalPairingStatuses.has(pairing.status) || cleanupPending) && (
<PairingProgressCard
pairing={pairing}
loading={loading}
onCancel={() => setConfirmAction('cancelPairing')}
onResolveConnectionConflict={() => setConfirmAction('retireSecurityEvent')}
/>
)}
{draft && pairing?.status === 'completed' && (
<Card>
<CardHeader>
<div><CardTitle></CardTitle><p className="mutedText"> SecretStore</p></div>
<Badge variant={draft.state === 'failed' ? 'destructive' : 'secondary'}>{draft.state}</Badge>
</CardHeader>
<CardContent className="pageStack">
<RevisionDetails revision={draft} />
{draft.state === 'draft' && (
<div className="formGrid two">
<Label>Gateway <Input value={localTenantKey} onChange={(event) => setLocalTenantKey(event.target.value)} /></Label>
<label className="identityCheckbox">
<input type="checkbox" checked={legacyJwtEnabled} onChange={(event) => setLegacyJwtEnabled(event.target.checked)} />
<span><strong> Legacy JWT</strong><small></small></span>
</label>
</div>
)}
<div className="fileStorageToolbar">
{draft.state === 'draft' && <>
<Button type="button" variant="outline" disabled={loading || !localTenantKey.trim()} onClick={() => void saveDraftPolicy()}></Button>
<Button type="button" disabled={loading || !localTenantKey.trim()}
onClick={() => void run(() => validateIdentityRevision(props.token, draft.id, draft.version), '统一认证配置验证成功,可以激活。')}>
<ShieldCheck size={14} />
</Button>
</>}
{draft.state === 'validated' && (
<Button type="button" disabled={loading}
onClick={() => void run(() => activateIdentityRevision(props.token, draft.id, draft.version), '统一认证已热切换启用,无需重启。', true)}>
<CheckCircle2 size={14} />
</Button>
)}
{draft.state === 'failed' && <span className="formMessage">{draft.lastErrorCategory || 'validation_failed'}使</span>}
<Button type="button" variant="outline" disabled={loading} onClick={() => setConfirmAction('cancelPairing')}>
稿
</Button>
</div>
</CardContent>
</Card>
)}
{pairing && terminalPairingStatuses.has(pairing.status) && pairing.status !== 'completed' && !cleanupPending && (
<div className="fileStorageToolbar">
<span className="formMessage" role="status">
{cleanupCompleted ? '旧配对已放弃,临时凭据与本次创建的 SSF 连接已清理。' : `配对${pairing.status === 'expired' ? '已过期' : '失败'}${pairingFailureMessage(pairing.lastErrorCategory)}`}
</span>
{cleanupCompleted
? <Button type="button" variant="outline" onClick={() => setShowPairingForm(true)}></Button>
: <Button type="button" variant="outline" disabled={loading} onClick={() => setConfirmAction('cancelPairing')}></Button>}
</div>
)}
{shouldShowPairing && (
<Card>
<CardHeader>
<div><CardTitle></CardTitle><p className="mutedText"> Application Manifest </p></div>
<Badge variant="outline"> 10 </Badge>
</CardHeader>
<CardContent>
<form className="formGrid two" onSubmit={(event) => void submitPairing(event)}>
<Label>Auth Center <Input required value={form.authCenterUrl} onChange={(event) => setForm({ ...form, authCenterUrl: event.target.value })} placeholder="https://auth.example.com" /></Label>
<Label><Input required type="password" autoComplete="one-time-code" value={form.onboardingCode} onChange={(event) => setForm({ ...form, onboardingCode: event.target.value })} placeholder="仅本次提交,不写入日志或数据库" /></Label>
<Label>Gateway API <Input required value={form.publicBaseUrl} onChange={(event) => setForm({ ...form, publicBaseUrl: event.target.value })} placeholder="https://api.gateway.example.com" /></Label>
<Label>Gateway Web <Input required value={form.webBaseUrl} onChange={(event) => setForm({ ...form, webBaseUrl: event.target.value })} placeholder="https://gateway.example.com" /></Label>
<Label>Gateway <Input required value={form.localTenantKey} onChange={(event) => setForm({ ...form, localTenantKey: event.target.value })} placeholder="default" /></Label>
<label className="identityCheckbox">
<input type="checkbox" checked={form.legacyJwtEnabled} onChange={(event) => setForm({ ...form, legacyJwtEnabled: event.target.checked })} />
<span><strong> Legacy JWT</strong><small></small></span>
</label>
<div className="fileStorageToolbar spanTwo">
<Button type="submit" disabled={loading || !form.onboardingCode.trim()}><Link2 size={15} /></Button>
</div>
</form>
</CardContent>
</Card>
)}
<ConfirmDialog
confirmLabel={confirmAction === 'cancelPairing'
? '确认放弃'
: confirmAction === 'retireSecurityEvent'
? '确认安全退役'
: '确认禁用'}
description={confirmAction === 'cancelPairing'
? '系统会封存当前未激活草稿,并异步销毁本地临时凭据和仅由本次配对创建的 SSF 连接。已经完成的远端 Exchange 不会被伪装为已撤销;使用新接入码时会轮换机器凭据。'
: confirmAction === 'retireSecurityEvent'
? '系统将安全退役当前 SSF Stream。退役期间现有统一认证会自动使用 Introspection 降级保护;退役完成后,本次配对会自动创建新的标准连接。'
: '统一认证将关闭并清理现有 BFF Session本地应急管理员登录继续可用。'}
loading={loading}
open={confirmAction !== null}
title={confirmAction === 'cancelPairing'
? '放弃当前本地配对?'
: confirmAction === 'retireSecurityEvent'
? '安全退役现有 SSF 连接?'
: '禁用统一认证?'}
onCancel={() => setConfirmAction(null)}
onConfirm={async () => {
let succeeded = false;
if (confirmAction === 'disable' && active) {
succeeded = await run(() => disableIdentityConfiguration(props.token, active.version), '统一认证已禁用,本地管理登录继续可用。', true);
} else if (confirmAction === 'cancelPairing' && pairing) {
succeeded = await run(() => cancelIdentityPairing(props.token, pairing.id, pairing.version), '已放弃当前草稿,后台正在安全清理临时资源。');
} else if (confirmAction === 'retireSecurityEvent') {
succeeded = await retireConflictingSecurityEventConnection();
}
if (succeeded) setConfirmAction(null);
}}
/>
</section>
);
}
export function PairingProgressCard({ pairing, loading, onCancel, onResolveConnectionConflict }: {
pairing: NonNullable<IdentityConfigurationView['pairing']>;
loading: boolean;
onCancel: () => void;
onResolveConnectionConflict?: () => void;
}) {
const cleaning = pairing.status === 'cancelled' && pairing.cleanupStatus === 'pending';
const connectionConflict = pairing.lastErrorCategory === 'security_event_connection_conflict';
const unsafeCredentialHandoff = pairing.lastErrorCategory === 'security_event_credential_handoff_unsafe';
return (
<Card>
<CardContent className="pageStack" aria-busy="true">
<div className="fileStorageToolbar" role="status" aria-live="polite">
<div><strong>{cleaning ? '正在清理已放弃的配对' : '正在配对认证中心'}</strong><span>{pairingStatusLabel(pairing.status, pairing.cleanupStatus)}</span></div>
<Badge variant="secondary">{cleaning ? 'cleanup_pending' : pairing.status}</Badge>
</div>
{pairing.lastErrorCategory && (
<div className="formMessage error" role="alert">
{pairingFailureMessage(pairing.lastErrorCategory)}{safePairingCategory(pairing.lastErrorCategory)}{cleaning
? '后台会继续安全清理。'
: connectionConflict
? '此问题不会通过自动重试恢复;请安全退役现有连接,或放弃本次配对。'
: unsafeCredentialHandoff
? '此问题不会自动恢复;请先在原配置下断开旧连接,或放弃本次配对。'
: '后台正在自动重试;也可以放弃本次配对后重新配置。'}
</div>
)}
<div className="fileStorageMeta">
<span>Exchange: {shortID(pairing.remoteExchangeId)}</span>
<span>: {new Date(pairing.expiresAt).toLocaleString()}</span>
{pairing.lastTraceId && <span>Trace ID: {pairing.lastTraceId}</span>}
{pairing.authCenterAuditId && <span>Auth Center Audit ID: {pairing.authCenterAuditId}</span>}
</div>
{!cleaning && <div className="fileStorageToolbar">
{connectionConflict && <Button type="button" variant="outline" disabled={loading} onClick={onResolveConnectionConflict}>退</Button>}
<Button type="button" variant="outline" disabled={loading} onClick={onCancel}></Button>
</div>}
</CardContent>
</Card>
);
}
export function IdentityRuntimeActions({
loading,
onDisable,
onValidate,
previous,
}: {
loading: boolean;
onDisable: () => void;
onValidate: () => void;
previous: IdentityConfigurationRevision | null;
}) {
return (
<div className="fileStorageToolbar">
<Button type="button" variant="outline" size="sm" disabled={loading} onClick={onValidate}>
<RefreshCw size={14} />
</Button>
<span className="mutedText" role="note">
Active OAuth/SSF 使
</span>
{previous && (
<span className="mutedText" role="note">
OAuth/SSF 使
</span>
)}
<Button type="button" variant="destructive" size="sm" disabled={loading} onClick={onDisable}>
<Unplug size={14} />
</Button>
</div>
);
}
function RevisionDetails({ revision }: { revision: IdentityConfigurationRevision }) {
return (
<div className="fileStorageMeta">
<span>Issuer: {revision.issuer || '等待 Manifest'}</span>
<span>Audience: {revision.audience || '等待 Manifest'}</span>
<span>Tenant: {revision.tenantId || '等待 Manifest'}</span>
<span>: {revision.localTenantKey}</span>
<span> Client: {revision.browserClientId || '未启用'}</span>
<span> Client: {revision.machineClientId || '未启用'}</span>
<span>: {revision.capabilities.map(capabilityLabel).join('、') || '等待 Manifest'}</span>
<span>Scope: {revision.scopes.join(', ') || '无'}</span>
{revision.lastTraceId && <span>Trace ID: {revision.lastTraceId}</span>}
{revision.lastAuditId && <span>Audit ID: {revision.lastAuditId}</span>}
</div>
);
}
function IdentityHealth({ label, value }: { label: string; value: string }) {
const healthy = value === 'healthy' || value === 'push_healthy';
return <div className="identityHealthItem"><span>{label}</span><Badge variant={healthy ? 'success' : value === 'disabled' ? 'outline' : 'secondary'}>{healthLabel(value)}</Badge></div>;
}
function defaultPairingInput(): IdentityPairingInput {
const webBaseUrl = typeof window === 'undefined' ? '' : window.location.origin;
const configuredApiBaseUrl = (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? '').replace(/\/$/, '');
return {
authCenterUrl: 'https://auth.51easyai.com',
onboardingCode: '',
publicBaseUrl: /^https?:\/\//i.test(configuredApiBaseUrl) ? configuredApiBaseUrl : '',
webBaseUrl,
localTenantKey: 'default',
legacyJwtEnabled: false,
};
}
function pairingStatusLabel(status: string, cleanupStatus?: string) {
if (status === 'cancelled' && cleanupStatus === 'pending') return '正在销毁临时凭据并清理由本次 Revision 创建的安全事件连接';
return ({ metadata_pending: '正在提交回调与 Receiver 地址', preparing: '认证中心正在创建或复用标准资源', ready: '正在领取并保存一次性机器凭据', credentials_saved: '正在建立安全事件能力并确认完成' } as Record<string, string>)[status] ?? status;
}
const pairingFailureMessages: Readonly<Record<string, string>> = {
security_event_configuration_invalid: '安全事件配置不完整或无效',
security_event_discovery_failed: '无法获取或校验 SSF Discovery网络、HTTP、JSON、Issuer 或端点异常)',
security_event_connection_conflict: '已有安全事件连接与本次接入配置冲突',
security_event_credential_handoff_unsafe: '旧安全事件连接与本次认证中心或服务 Client 不匹配;系统不会发送旧凭据,请先在原配置下断开旧连接',
security_event_connection_binding_missing: '安全事件连接尚未建立',
security_event_connection_binding_unavailable: '安全事件连接状态暂时不可用',
security_event_connection_binding_invalid: '安全事件连接缺少 Revision 绑定信息',
security_event_connection_binding_mismatch: '认证中心返回的安全事件 Audience 或连接归属与本次配置不一致',
security_event_retirement_pending: '现有安全事件连接正在安全退役,完成后本次配对会自动继续',
security_event_management_token_failed: '服务客户端暂时无法取得安全事件管理 Token',
security_event_stream_create_failed: '安全事件 Stream 创建失败',
security_event_stream_response_invalid: '安全事件 Stream 返回内容不符合预期',
security_event_receiver_activation_failed: 'Gateway 安全事件 Receiver 启动失败',
security_event_preparation_failed: '安全事件能力准备失败',
metadata_submission_failed: '业务地址提交失败',
exchange_status_unavailable: '认证中心资源准备状态暂时不可用',
credential_delivery_failed: '一次性机器凭据领取或保存失败',
exchange_expired: '一次性接入 Exchange 已过期',
remote_exchange_failed: '认证中心未能完成应用资源准备,请结合 Audit ID 排查',
remote_state_invalid: '认证中心返回了不符合当前流程的 Exchange 状态',
exchange_completion_failed: 'Gateway 暂时无法确认认证中心 Exchange 已完成',
cleanup_revision_unavailable: '待清理的统一认证草稿暂时不可用',
cleanup_security_event_unavailable: '安全事件清理服务暂时不可用',
cleanup_security_event_retirement_pending: '安全事件 Stream 已断开,正在等待安全退役窗口结束',
cleanup_security_event_connection_cleanup_failed: '安全事件连接清理暂时失败',
cleanup_security_event_secret_cleanup_failed: '安全事件临时凭据清理暂时失败',
cleanup_security_event_failed: '安全事件连接清理暂时失败',
cleanup_secret_store_failed: '统一认证临时凭据清理暂时失败',
cleanup_finalize_failed: '清理结果暂时无法确认',
};
export function pairingFailureMessage(category?: string) {
return pairingFailureMessages[category ?? ''] ?? '统一认证配对步骤暂时失败';
}
function safePairingCategory(category: string) {
return Object.hasOwn(pairingFailureMessages, category) ? category : 'pairing_step_failed';
}
export function isStaleIdentityOperation(caught: unknown) {
return caught instanceof GatewayApiError && (caught.details.status === 409 || caught.details.status === 412);
}
export async function executeIdentityOperation(options: {
action: () => Promise<unknown>;
onRuntimeChanged: () => void;
onSuccess: (message: string) => void;
refresh: () => Promise<unknown>;
runtimeChanged: boolean;
success: string;
}) {
await options.action();
try {
await options.refresh();
} catch (caught) {
if (!options.runtimeChanged) throw caught;
const followUp = isIdentityRefreshAuthenticationLoss(caught)
? '当前统一认证会话已失效,请重新登录。'
: '最新状态刷新失败,请稍后点击“刷新”确认。';
options.onSuccess(`${options.success} 操作已提交;${followUp}`);
options.onRuntimeChanged();
return true;
}
options.onSuccess(options.success);
if (options.runtimeChanged) options.onRuntimeChanged();
return true;
}
function isIdentityRefreshAuthenticationLoss(caught: unknown) {
return caught instanceof GatewayApiError && caught.details.status === 401;
}
function capabilityLabel(value: string) {
return ({ oidc_login: 'OIDC 登录', api_access: 'API 验证', token_introspection: 'Token Introspection', machine_to_machine: '机器调用', session_revocation: 'SSF 会话撤销' } as Record<string, string>)[value] ?? value;
}
function healthLabel(value: string) {
return ({ healthy: '健康', disabled: '未启用', push_healthy: 'Push 健康', introspection_fallback: 'Introspection 降级', bootstrap: '启动保护' } as Record<string, string>)[value] ?? value;
}
function shortID(value: string) { return value ? `${value.slice(0, 8)}` : '-'; }
function errorMessage(caught: unknown, fallback: string) { return caught instanceof Error ? caught.message : fallback; }

View File

@ -1995,6 +1995,44 @@
line-height: 1.45;
}
.identityHealthGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.identityHealthItem {
align-items: center;
background: var(--color-surface-subtle, #f8fafc);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 10px;
display: flex;
justify-content: space-between;
min-height: 52px;
padding: 10px 12px;
}
.identityCheckbox {
align-items: center;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 10px;
display: flex;
gap: 10px;
min-height: 66px;
padding: 10px 12px;
}
.identityCheckbox span,
.identityCheckbox small {
display: block;
}
@media (max-width: 900px) {
.identityHealthGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.fileStorageGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));

View File

@ -0,0 +1,34 @@
# Reference overlay for installations that run the Gateway in Kubernetes.
# Keep the Secret empty: the Gateway writes only generated Push Bearers.
apiVersion: v1
kind: Secret
metadata:
name: easyai-gateway-security-events
namespace: easyai
type: Opaque
data: {}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: easyai-gateway-security-events
namespace: easyai
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["easyai-gateway-security-events"]
verbs: ["get", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: easyai-gateway-security-events
namespace: easyai
subjects:
- kind: ServiceAccount
name: easyai-ai-gateway
namespace: easyai
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: easyai-gateway-security-events

View File

@ -6,25 +6,11 @@ x-api-environment: &api-environment
AI_GATEWAY_DATABASE_URL: ${AI_GATEWAY_COMPOSE_DATABASE_URL:-postgresql://easyai:easyai2025@postgres:5432/easyai_ai_gateway?sslmode=disable}
CONFIG_JWT_SECRET: ${CONFIG_JWT_SECRET:-this is a very secret secret}
IDENTITY_MODE: ${AI_GATEWAY_COMPOSE_IDENTITY_MODE:-hybrid}
OIDC_ENABLED: ${OIDC_ENABLED:-false}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_AUDIENCE: ${OIDC_AUDIENCE:-}
OIDC_TENANT_ID: ${OIDC_TENANT_ID:-}
OIDC_ROLE_PREFIX: ${OIDC_ROLE_PREFIX:-gateway.}
OIDC_REQUIRED_SCOPES: ${OIDC_REQUIRED_SCOPES:-gateway.access}
OIDC_JWKS_CACHE_TTL_SECONDS: ${OIDC_JWKS_CACHE_TTL_SECONDS:-300}
OIDC_ACCEPT_LEGACY_HS256: ${OIDC_ACCEPT_LEGACY_HS256:-true}
OIDC_JIT_PROVISIONING_ENABLED: ${OIDC_JIT_PROVISIONING_ENABLED:-false}
OIDC_GATEWAY_TENANT_KEY: ${OIDC_GATEWAY_TENANT_KEY:-}
OIDC_BROWSER_SESSION_ENABLED: ${OIDC_BROWSER_SESSION_ENABLED:-true}
OIDC_SESSION_COOKIE_SECURE: ${OIDC_SESSION_COOKIE_SECURE:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-}
OIDC_POST_LOGOUT_REDIRECT_URI: ${OIDC_POST_LOGOUT_REDIRECT_URI:-}
OIDC_SESSION_ENCRYPTION_KEY: ${OIDC_SESSION_ENCRYPTION_KEY:-}
OIDC_SESSION_IDLE_TTL_SECONDS: ${OIDC_SESSION_IDLE_TTL_SECONDS:-1800}
OIDC_SESSION_ABSOLUTE_TTL_SECONDS: ${OIDC_SESSION_ABSOLUTE_TTL_SECONDS:-28800}
OIDC_SESSION_REFRESH_BEFORE_SECONDS: ${OIDC_SESSION_REFRESH_BEFORE_SECONDS:-60}
IDENTITY_SECRET_STORE: file
IDENTITY_SECRET_DIR: /app/data/identity/secrets
IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS: ${IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS:-60}
IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS: ${IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS:-180}
IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS: ${IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS:-60}
SERVER_MAIN_BASE_URL: ${AI_GATEWAY_COMPOSE_SERVER_MAIN_BASE_URL:-http://host.docker.internal:3000}
SERVER_MAIN_INTERNAL_TOKEN: ${SERVER_MAIN_INTERNAL_TOKEN:-change-me}
SERVER_MAIN_INTERNAL_KEY: ${SERVER_MAIN_INTERNAL_KEY:-gateway}
@ -119,8 +105,6 @@ services:
VITE_GATEWAY_API_BASE_URL: ${AI_GATEWAY_WEB_API_BASE_URL:-/gateway-api}
NODE_BUILD_IMAGE: ${AI_GATEWAY_NODE_BUILD_IMAGE:-node:22-alpine}
WEB_RUNTIME_IMAGE: ${AI_GATEWAY_WEB_RUNTIME_IMAGE:-nginx:1.27-alpine}
VITE_OIDC_ENABLED: ${OIDC_ENABLED:-false}
VITE_OIDC_BROWSER_SESSION_ENABLED: ${OIDC_BROWSER_SESSION_ENABLED:-true}
VITE_BASE_PATH: ${AI_GATEWAY_WEB_BASE_PATH:-/}
ports:
- "${AI_GATEWAY_WEB_PORT:-5178}:80"

View File

@ -10,6 +10,29 @@ server {
return 308 /gateway-api/;
}
# RFC 8935 receiver: exact path, no redirect, short bounded upstream timeout.
location = /api/v1/security-events/ssf {
client_max_body_size 64k;
client_body_buffer_size 64k;
client_body_timeout 5s;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_connect_timeout 3s;
proxy_read_timeout 10s;
proxy_send_timeout 10s;
proxy_redirect off;
proxy_pass http://api:8088;
}
# Metrics stay on the API's monitoring network and are not exposed by Web.
location = /gateway-api/metrics {
return 404;
}
location /gateway-api/ {
proxy_http_version 1.1;
proxy_set_header Host $host;

View File

@ -4,28 +4,15 @@
Gateway 只在 OIDC Token 已通过签名、Issuer、Audience、有效期、`tid`、Scope 和应用角色校验后执行 JIT。认证中心的稳定 `sub` 是外部用户标识Gateway 不读取、不保存或公开 Keycloak 内部 ID也不向 Token 增加 `gatewayUserId`
本次保持单 Gateway 租户:部署方用 `OIDC_GATEWAY_TENANT_KEY` 把 Auth Center 的已验证 `tid` 显式绑定到一个已存在、启用且配置了启用中默认用户组的 Gateway 租户。Token 不能创建租户。
本次保持单 Gateway 租户:管理员在统一认证 Draft 中把 Auth Center 的已验证 `tid` 显式绑定到一个已存在、启用且配置了启用中默认用户组的 Gateway 租户。Token 不能创建租户。
## 配置
```dotenv
OIDC_ENABLED=true
OIDC_JIT_PROVISIONING_ENABLED=true
OIDC_GATEWAY_TENANT_KEY=default
OIDC_BROWSER_SESSION_ENABLED=true
OIDC_SESSION_COOKIE_SECURE=false # 本地 HTTP生产必须为 true
OIDC_CLIENT_ID=<Control Plane 生成的公共 Client ID>
OIDC_REDIRECT_URI=http://localhost:8088/api/v1/auth/oidc/callback
OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5178/
OIDC_SESSION_ENCRYPTION_KEY=<独立的 32 字节随机密钥base64 编码>
OIDC_SESSION_IDLE_TTL_SECONDS=1800
OIDC_SESSION_ABSOLUTE_TTL_SECONDS=28800
OIDC_SESSION_REFRESH_BEFORE_SECONDS=60
```
OIDC、JIT 和浏览器会话不读取业务环境变量。管理员在 Auth Center 的通用“应用接入”向导选择 `OIDC 登录``API 验证`,生成一次性接入码;再到 Gateway“系统设置 → 统一认证”提交认证中心地址、接入码、Gateway API/Web 地址和本地租户映射。系统自动推导回调及退出地址,生成 Session 加密密钥并保存到部署级 SecretStore。
- `OIDC_JIT_PROVISIONING_ENABLED` 默认 `false`。关闭时停止创建新用户,但已有 `source=oidc + external_user_id=sub` 映射仍会解析和同步登录时间
- JIT 开启时 `OIDC_GATEWAY_TENANT_KEY` 必填,缺失会使 Gateway 启动失败
- 本地与 Staging 验收环境应在各自 Git 忽略或 Secret 管理的环境文件中显式开启;生产启用需独立变更审批
- JIT、Legacy JWT 兼容和 Session 时限属于 Revision 策略,可在 Draft 中修改;默认闲置 30 分钟、绝对 8 小时、提前 60 秒刷新。
- JIT 关闭时停止创建新用户,但已有 `source=oidc + external_user_id=sub` 映射仍会解析和同步登录时间。
- 本地租户不存在、公共 Client 不可用或 Discovery/Issuer/JWKS 校验失败时Revision 不能激活,当前 Active Runtime 不受影响。
## Web Console 浏览器会话
@ -34,10 +21,11 @@ OIDC_SESSION_REFRESH_BEFORE_SECONDS=60
- 浏览器只保存 32 字节随机、`HttpOnly + SameSite=Strict` 的 Session Cookie。新标签页通过该 Cookie 调用 `/api/v1/me` 恢复登录态Gateway 不签发第二枚 JWT。
- Access Token 继续保持 5 分钟。认证请求发现剩余时间不超过 60 秒时使用 Refresh Token 自动刷新;多实例通过 PostgreSQL 刷新租约保证同一版本只刷新一次,并原子保存旋转后的 Refresh Token。
- Session 闲置 30 分钟、绝对最长 8 小时。闲置 530 分钟后的首个请求可自动刷新;超过闲置或绝对期限不会刷新,必须重新登录。浏览器不运行定时刷新。
- 所有 Web API 请求使用 `credentials: include`。Cookie 鉴权的 POST、PUT、PATCH、DELETE 必须携带 `CORS_ALLOWED_ORIGIN` 白名单中的 Origin否则返回结构化 403
- Staging、生产及其他非本地环境启动时强制 `OIDC_SESSION_COOKIE_SECURE=true`,并拒绝带 `*` 的凭据型 CORS 配置。本地 HTTP 开发和自动化测试可以显式设为 `false`。
- 所有 Web API 请求使用 `credentials: include`。Cookie 鉴权请求的 Origin 必须与当前健康 Active Revision 的 Gateway Web 地址精确匹配,否则返回结构化 403不接受 `*`
- Cookie 的 `Secure` 属性由 Revision 中的 Gateway API 公网地址推导:生产地址只允许 HTTPS本地开发允许 localhost HTTP。可信 Origin 由 Gateway Web 地址精确推导,不接受 `*`。
- `POST /api/v1/auth/oidc/logout` 校验可信 Origin、删除本地 Session、使用公共 Client ID 撤销 Refresh Token并跳转 Auth Center 退出地址;`DELETE /api/v1/auth/oidc/session` 保留为幂等的本地删除接口。
- `OIDC_SESSION_ENCRYPTION_KEY` 必须来自 Git 忽略的本地文件、Kubernetes Secret 或 Secret Manager不得复用 JWT Secret。关闭 `OIDC_BROWSER_SESSION_ENABLED` 可停止新会话;回滚镜像后已创建记录作为惰性数据保留。
- 第一次接入、确认禁用或 Active Runtime 故障恢复时,跨 Origin 管理页面使用部署级精确 bootstrap CORS生产部署建议由反向代理通过同源 `/gateway-api` 暴露 API。该配置不包含 Issuer、Client 或 Scope。
- Session Encryption Key 由 Gateway 服务端生成,只以 Secret 引用进入 Revision不得复用 JWT Secret也不会进入数据库、响应、日志或浏览器。禁用统一认证会清理现有 BFF Session重新配对后用户需要重新登录。
## 数据和事务语义
@ -66,8 +54,8 @@ OIDC_SESSION_REFRESH_BEFORE_SECONDS=60
`ErrLocalUserRequired` 仅作为防御性内部错误保留,对外统一转换为结构化 403。
## 验收与回滚
## 验收与重新接入
自动化门禁通过后,依次验证本地真实 OIDC 和 `auth.51easyai.com` Staging 专用测试租户。证据应包含脱敏 Claims、网络截图、本地 Gateway 用户记录、Trace ID、Gateway/Auth Center 审计 ID及 200/401/403/503 负向证据;不得保存 Token、授权码、密码或 Secret。
JIT 回滚时关闭 `OIDC_JIT_PROVISIONING_ENABLED` 并回退 Gateway 镜像。浏览器 Cookie 会话可通过后端和 Web 两侧的独立开关关闭。已经创建的 `source=oidc` 测试投影保留为惰性数据,不自动删除、迁移或合并。
Active Revision 可原位重新验证。需要改变远端身份资源时,先确认本地 Break-glass Manager禁用当前配置再使用 Auth Center 新接入码创建、验证和激活新 Revision历史 `superseded` Revision 只用于审计,不能直接回滚或重新激活。已经创建的 `source=oidc` 测试投影保留为惰性数据,不自动删除、迁移或合并。

View File

@ -0,0 +1,80 @@
# SSF/CAEP 实时会话撤销运行手册
Gateway 可选接收 Auth Center 通过 RFC 8935 Push 投递的 RFC 8417 Security Event Token并处理 OpenID CAEP `session-revoked`。能力由 Active Identity Revision 控制;没有选择会话撤销时保持原有 OIDC、BFF、API Key 和 Legacy JWT 行为。
## 用户接入流程
首次接入只执行两端各一次操作:
1. 在认证中心 Application 的通用“应用接入”向导选择“SSF 会话撤销”。能力依赖会自动包含 Token Introspection 和机器调用;预览确认后创建或复用同用途服务客户端。
2. 在 Gateway“系统设置 → 统一认证”填写一次性接入码和 Gateway 地址。Gateway 自动领取 Manifest 与机器凭据,并在验证候选 Runtime 时建立 SSF Stream。
Gateway 后端先把 Machine Secret 写入 SecretStore随后自动读取 Discovery、生成并托管 256 bit Push Bearer、创建 paused Stream、完成 Verification、首次启用 Stream并进入 360 秒 RFC 7662 bootstrap。接入码和 Machine Secret 只从浏览器提交到 Gateway 服务端Push Bearer 和 Secret 引用始终不可见,数据库、响应和日志不保存明文。管理员不编辑 Secret 文件,也不需要重启 Gateway。
环境前置配置只保留 SecretStore 与运行时基础设施参数:
- `IDENTITY_SECRET_STORE`、`IDENTITY_SECRET_DIR`,或对应 Kubernetes SecretStore 参数;
- `IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS`、`IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS` 和 `IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS`
Issuer、Tenant、Gateway 公网地址和机器 Client 均来自 Active Revision不读取旧 OIDC 环境变量。OIDC fallback、SSF 管理 Token 和 Verification 共用一份托管机器凭据,重启后继续生效。
第一版一个 Gateway 部署只允许一个认证中心连接。下游业务服务无需接入 SSF。
## SecretStore
本地和 Docker 使用 `file` 驱动。服务自动创建目录并强制目录 `0700`、文件 `0600`Docker Compose 将目录放在持久化 `api_data` 卷中。用户不写入任何 Secret 文件。
Kubernetes 使用 `kubernetes` 驱动和一个部署时预创建的空 Secret
```text
IDENTITY_SECRET_STORE=kubernetes
IDENTITY_KUBERNETES_NAMESPACE=easyai
IDENTITY_KUBERNETES_SECRET_NAME=easyai-gateway-identity
```
参考清单见 `deploy/kubernetes/security-events-secret-rbac.yaml`。ServiceAccount 只能对这个固定 Secret 执行 `get/update/patch`,不能创建、删除或读取其他 Secret。数据库、API、浏览器、日志和审计只保存或展示非敏感连接元数据。
## 动态运行与状态
Receiver 路由始终注册:没有连接时返回无敏感信息的 `404`;存在连接但凭据丢失时返回 `503` 并进入 `introspection_fallback`。OIDC Evaluator 始终挂载但在无连接时无副作用。
连接生命周期包括 `connecting`、`verifying`、`bootstrap`、`enabled`、`degraded`、`rotating`、`disconnect_pending` 和 `retiring`。健康时每 60 秒一次 Verification不随业务请求 QPS 增长180 秒无匹配 Verification 时切换 RFC 7662。内省也不可用时 OIDC Fail ClosedAPI Key 继续工作。
Verification 成功后仅在首次连接和主动轮换时自动启用 Stream。认证中心管理员之后手工暂停或禁用 StreamGateway 的“重新验证”只检查链路,不会擅自恢复远端状态。
## 轮换与断开
“轮换凭据”由 Gateway 自动完成:生成 next、Receiver 同时接受 current/next、暂停并更新远端 Stream、Verification、重新启用、保留旧值至少 180 秒,最后提升 next 并删除旧值。中途失败保留两个凭据和 `rotating` 状态,可安全重试。
“断开连接”先删除远端 Stream再进入至少 360 秒 `retiring`,期间继续应用撤销水位和 RFC 7662避免旧 Token 因关闭功能重新有效。远端不可用时保持 `disconnect_pending` 并指数退避重试;不会提前删除 Push Secret。收据、水位和脱敏审计数据不会清表。
禁用统一认证也遵循相同顺序:若 Active Revision 开启了 SSF只有远端 Stream 已删除且本地连接进入 `retiring`Active 才会变为 `superseded`BFF Session 才会清理。`disconnect_pending` 时禁用返回明确的 retirement pending数据库 Active 保持不变;即使完整 OIDC Runtime 已 fail closedGateway 也会只重建 SSF 管理面完成断开,不要求 Discovery、JIT 或 BFF 先恢复。`retiring` 最终清理由服务生命周期 Worker 托管Gateway 重启后仍会继续。
## 旧连接交接与配对恢复
Pairing 停在 `credentials_saved`Manifest 和机器凭据已经保存,但 Stream 可能仍在创建、验证或退役:
- 暂时性 Discovery/Transmitter 错误和 `security_event_retirement_pending` 会自动重试。
- `security_event_connection_conflict` 只允许通过当前 Pairing 的“安全退役现有连接”处理。
- `security_event_credential_handoff_unsafe` 表示旧连接与新 Issuer/Client 不匹配;系统不会把旧凭据发往新端点。管理员应在原配置下断开,或放弃当前 Pairing。
- 放弃后等待 `cleanup_status=completed`,再从 Auth Center 生成新接入码重新配对。
若旧连接与新配置具备可证明的同一管理边界Gateway 仍不会直接覆盖 S1先用新机器凭据 S2 从新 OIDC Issuer 取得管理 Token再经过认证读取旧 Transmitter存在 Stream 时精确校验 Stream ID、Issuer、Receiver 和 Audience。证明成功后才原子切换凭据引用并把 S1 交给持久清理队列;失败时 S1/S2 引用和远端连接都保持不变。
所有写管理接口要求 Gateway `Manager` 权限、`Idempotency-Key` 和 `If-Match`
```text
GET /api/admin/system/identity/security-events/connection
PUT /api/admin/system/identity/security-events/connection
POST /api/admin/system/identity/security-events/connection/verify
POST /api/admin/system/identity/security-events/connection/rotate-credential
DELETE /api/admin/system/identity/security-events/connection
```
## 监控、退役与验收
`GET /metrics` 输出接收结果、删除 Session 数、水位拒绝数、Verification 年龄、健康模式、内省结果、JWKS 失败和处理延迟。至少告警 Verification age 超过 120 秒、fallback 超过 5 分钟、内省失败、SET 拒绝增长以及撤销 P99 超过 3 秒。日志只记录脱敏 Trace/Audit ID 和错误类别。
当前禁止直接激活历史 `superseded` Revision因为 Auth Center 会复用并修改远端 OAuth/SSF 资源,旧快照无法证明仍然匹配。恢复配置时先通过 Gateway 安全禁用并退役 Stream再使用 Auth Center 新接入码重新配对保留迁移表、水位和审计。Legacy HS256 Token 和 API Key 不在本协议撤销范围内。
真实链路只使用明确标记为测试用途的 Application并且必须在自动化测试后执行。报告包含脱敏 Trace、Claims、截图、Audit ID、投递延迟、fallback 和恢复证据;需要人工或第三方操作时记录 `SKIPPED_HUMAN_OR_THIRD_PARTY_REQUIRED`

Some files were not shown because too many files have changed in this diff Show More