Merge pull request 'feat(identity): 合并标准统一认证接入与安全事件撤销' (#7) from codex/merge-identity-main into main
Some checks failed
ci / verify (push) Failing after 34s
Some checks failed
ci / verify (push) Failing after 34s
Reviewed-on: #7
This commit is contained in:
commit
82a494051d
44
.env.example
44
.env.example
@ -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
1
.gitignore
vendored
@ -5,6 +5,7 @@ node_modules/
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
.local-secrets/
|
||||
.gateway-local-password
|
||||
*.log
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
24
README.md
24
README.md
@ -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 Session:Gateway 服务端换码并加密保存 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,然后并行启动:
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
360
apps/api/cmd/migrate/main_test.go
Normal file
360
apps/api/cmd/migrate/main_test.go
Normal 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
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
820
apps/api/internal/httpapi/identity_configuration_handlers.go
Normal file
820
apps/api/internal/httpapi/identity_configuration_handlers.go
Normal 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):
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@ -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) }
|
||||
126
apps/api/internal/httpapi/identity_pairing_coordinator.go
Normal file
126
apps/api/internal/httpapi/identity_pairing_coordinator.go
Normal 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
|
||||
})
|
||||
}
|
||||
150
apps/api/internal/httpapi/identity_pairing_coordinator_test.go
Normal file
150
apps/api/internal/httpapi/identity_pairing_coordinator_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
108
apps/api/internal/httpapi/identity_runtime.go
Normal file
108
apps/api/internal/httpapi/identity_runtime.go
Normal 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)
|
||||
}
|
||||
26
apps/api/internal/httpapi/local_login_policy_test.go
Normal file
26
apps/api/internal/httpapi/local_login_policy_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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 ""
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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),
|
||||
})
|
||||
|
||||
@ -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{
|
||||
|
||||
385
apps/api/internal/httpapi/security_event_connection_handlers.go
Normal file
385
apps/api/internal/httpapi/security_event_connection_handlers.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
30
apps/api/internal/httpapi/security_events.go
Normal file
30
apps/api/internal/httpapi/security_events.go
Normal 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)
|
||||
}
|
||||
56
apps/api/internal/httpapi/security_events_test.go
Normal file
56
apps/api/internal/httpapi/security_events_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
298
apps/api/internal/identity/configuration.go
Normal file
298
apps/api/internal/identity/configuration.go
Normal 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()
|
||||
}
|
||||
127
apps/api/internal/identity/configuration_test.go
Normal file
127
apps/api/internal/identity/configuration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
304
apps/api/internal/identity/onboarding_client.go
Normal file
304
apps/api/internal/identity/onboarding_client.go
Normal 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
|
||||
}
|
||||
155
apps/api/internal/identity/onboarding_client_test.go
Normal file
155
apps/api/internal/identity/onboarding_client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
632
apps/api/internal/identity/pairing_service.go
Normal file
632
apps/api/internal/identity/pairing_service.go
Normal 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,
|
||||
})
|
||||
}
|
||||
880
apps/api/internal/identity/pairing_service_test.go
Normal file
880
apps/api/internal/identity/pairing_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
419
apps/api/internal/identityruntime/builder.go
Normal file
419
apps/api/internal/identityruntime/builder.go
Normal 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"
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
476
apps/api/internal/identityruntime/manager.go
Normal file
476
apps/api/internal/identityruntime/manager.go
Normal 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)
|
||||
}
|
||||
667
apps/api/internal/identityruntime/manager_test.go
Normal file
667
apps/api/internal/identityruntime/manager_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
1414
apps/api/internal/securityevents/connection_manager.go
Normal file
1414
apps/api/internal/securityevents/connection_manager.go
Normal file
File diff suppressed because it is too large
Load Diff
1650
apps/api/internal/securityevents/connection_manager_test.go
Normal file
1650
apps/api/internal/securityevents/connection_manager_test.go
Normal file
File diff suppressed because it is too large
Load Diff
185
apps/api/internal/securityevents/handler.go
Normal file
185
apps/api/internal/securityevents/handler.go
Normal 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."
|
||||
}
|
||||
}
|
||||
269
apps/api/internal/securityevents/handler_test.go
Normal file
269
apps/api/internal/securityevents/handler_test.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
186
apps/api/internal/securityevents/kubernetes_secret_store.go
Normal file
186
apps/api/internal/securityevents/kubernetes_secret_store.go
Normal 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)
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
195
apps/api/internal/securityevents/metrics.go
Normal file
195
apps/api/internal/securityevents/metrics.go
Normal 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)
|
||||
}
|
||||
50
apps/api/internal/securityevents/metrics_test.go
Normal file
50
apps/api/internal/securityevents/metrics_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
79
apps/api/internal/securityevents/retirement_worker.go
Normal file
79
apps/api/internal/securityevents/retirement_worker.go
Normal 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)
|
||||
}
|
||||
178
apps/api/internal/securityevents/retirement_worker_test.go
Normal file
178
apps/api/internal/securityevents/retirement_worker_test.go
Normal 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")
|
||||
}
|
||||
112
apps/api/internal/securityevents/secret_store.go
Normal file
112
apps/api/internal/securityevents/secret_store.go
Normal 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
|
||||
}
|
||||
40
apps/api/internal/securityevents/secret_store_test.go
Normal file
40
apps/api/internal/securityevents/secret_store_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
222
apps/api/internal/securityevents/service.go
Normal file
222
apps/api/internal/securityevents/service.go
Normal 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
|
||||
}
|
||||
86
apps/api/internal/securityevents/service_test.go
Normal file
86
apps/api/internal/securityevents/service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
412
apps/api/internal/securityevents/verifier.go
Normal file
412
apps/api/internal/securityevents/verifier.go
Normal 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"
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
460
apps/api/internal/store/identity_configurations.go
Normal file
460
apps/api/internal/store/identity_configurations.go
Normal 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
|
||||
}
|
||||
407
apps/api/internal/store/identity_pairing.go
Normal file
407
apps/api/internal/store/identity_pairing.go
Normal 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
|
||||
}
|
||||
711
apps/api/internal/store/identity_pairing_integration_test.go
Normal file
711
apps/api/internal/store/identity_pairing_integration_test.go
Normal 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}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
477
apps/api/internal/store/security_event_connections.go
Normal file
477
apps/api/internal/store/security_event_connections.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
372
apps/api/internal/store/security_events.go
Normal file
372
apps/api/internal/store/security_events.go
Normal 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(¤tStreamID); 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]
|
||||
}
|
||||
185
apps/api/internal/store/security_events_integration_test.go
Normal file
185
apps/api/internal/store/security_events_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
10
apps/api/internal/store/security_events_test.go
Normal file
10
apps/api/internal/store/security_events_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
70
apps/api/migrations/0063_oidc_security_events.sql
Normal file
70
apps/api/migrations/0063_oidc_security_events.sql
Normal 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);
|
||||
46
apps/api/migrations/0064_security_event_connections.sql
Normal file
46
apps/api/migrations/0064_security_event_connections.sql
Normal 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);
|
||||
@ -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)
|
||||
);
|
||||
55
apps/api/migrations/0066_identity_onboarding_exchanges.sql
Normal file
55
apps/api/migrations/0066_identity_onboarding_exchanges.sql
Normal 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';
|
||||
16
apps/api/migrations/0067_identity_secret_cleanup_queue.sql
Normal file
16
apps/api/migrations/0067_identity_secret_cleanup_queue.sql
Normal 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);
|
||||
@ -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;
|
||||
@ -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": {
|
||||
|
||||
@ -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 [];
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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[];
|
||||
|
||||
29
apps/web/src/components/ui/confirm-dialog.test.tsx
Normal file
29
apps/web/src/components/ui/confirm-dialog.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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() },
|
||||
|
||||
@ -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') ?? '';
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"
|
||||
|
||||
214
apps/web/src/pages/admin/UnifiedIdentityPanel.test.tsx
Normal file
214
apps/web/src/pages/admin/UnifiedIdentityPanel.test.tsx
Normal 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',
|
||||
};
|
||||
}
|
||||
488
apps/web/src/pages/admin/UnifiedIdentityPanel.tsx
Normal file
488
apps/web/src/pages/admin/UnifiedIdentityPanel.tsx
Normal 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 自动配置 OIDC、Introspection 与可选 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; }
|
||||
@ -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));
|
||||
|
||||
34
deploy/kubernetes/security-events-secret-rbac.yaml
Normal file
34
deploy/kubernetes/security-events-secret-rbac.yaml
Normal 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
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 小时。闲置 5~30 分钟后的首个请求可自动刷新;超过闲置或绝对期限不会刷新,必须重新登录。浏览器不运行定时刷新。
|
||||
- 所有 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` 测试投影保留为惰性数据,不自动删除、迁移或合并。
|
||||
|
||||
80
docs/security/ssf-session-revocation.md
Normal file
80
docs/security/ssf-session-revocation.md
Normal 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 Closed,API Key 继续工作。
|
||||
|
||||
Verification 成功后仅在首次连接和主动轮换时自动启用 Stream。认证中心管理员之后手工暂停或禁用 Stream,Gateway 的“重新验证”只检查链路,不会擅自恢复远端状态。
|
||||
|
||||
## 轮换与断开
|
||||
|
||||
“轮换凭据”由 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 closed,Gateway 也会只重建 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
Loading…
Reference in New Issue
Block a user