feat(identity): 接入认证中心多租户登录
支持 Manifest V2 动态 tid 验证、Tenant Context 同步和租户内 JIT 投影,并保留 Manifest V1 与旧 Session 兼容。\n\n增加 tenantHint、租户切换、普通注册关闭及 application/principal/tenant 两级 SSF 撤销;迁移、定向安全测试和本地双租户跨仓 E2E 已通过。\n\nrelease_required=true;未执行 Release、Staging 或真实链路。
This commit is contained in:
@@ -21,10 +21,11 @@ type IdentityManagementRequest struct {
|
||||
}
|
||||
|
||||
const identityRevisionColumns = `
|
||||
id::text,state,schema_version,auth_center_url,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(application_id,''),
|
||||
id::text,state,schema_version,tenant_mode,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(tenant_context_endpoint,''),COALESCE(tenant_context_audience,''),COALESCE(tenant_context_scope,''),
|
||||
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`
|
||||
@@ -83,11 +84,11 @@ func (s *Store) CreateIdentityConfigurationRevision(ctx context.Context, revisio
|
||||
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,
|
||||
id,state,schema_version,tenant_mode,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)
|
||||
) VALUES ($1::uuid,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13::jsonb,$14,$15,$16)
|
||||
RETURNING `+identityRevisionColumns,
|
||||
revision.ID, revision.State, revision.SchemaVersion, revision.AuthCenterURL, revision.RolePrefix,
|
||||
revision.ID, revision.State, revision.SchemaVersion, revision.TenantMode, revision.AuthCenterURL, revision.RolePrefix,
|
||||
revision.LocalTenantKey, revision.PublicBaseURL, revision.WebBaseURL, revision.JITEnabled, revision.LegacyJWTEnabled,
|
||||
string(scopes), string(capabilities), revision.SessionIdleSeconds, revision.SessionAbsoluteSeconds, revision.SessionRefreshSeconds,
|
||||
))
|
||||
@@ -198,16 +199,19 @@ FROM gateway_identity_configuration_revisions WHERE id=$1::uuid FOR UPDATE`, id)
|
||||
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()
|
||||
schema_version=$3,tenant_mode=$4,issuer=$5,tenant_id=NULLIF($6,''),application_id=$7,audience=NULLIF($8,''),
|
||||
browser_client_id=NULLIF($9,''),machine_client_id=NULLIF($10,''),local_tenant_key=$11,
|
||||
scopes=$12::jsonb,capabilities=$13::jsonb,token_introspection=$14,session_revocation=$15,
|
||||
security_event_issuer=NULLIF($16,''),security_event_configuration_url=NULLIF($17,''),security_event_audience=NULLIF($18,''),
|
||||
tenant_context_endpoint=NULLIF($19,''),tenant_context_audience=NULLIF($20,''),tenant_context_scope=NULLIF($21,''),
|
||||
machine_credential_ref=NULLIF($22,''),session_encryption_key_ref=NULLIF($23,''),last_trace_id=NULLIF($24,''),
|
||||
last_audit_id=NULLIF($25,''),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,
|
||||
id, expectedVersion, updated.SchemaVersion, updated.TenantMode, updated.Issuer, updated.TenantID, updated.ApplicationID, updated.Audience,
|
||||
updated.BrowserClientID, updated.MachineClientID, updated.LocalTenantKey, string(scopes), string(capabilities), updated.TokenIntrospection,
|
||||
updated.SessionRevocation, updated.SecurityEventIssuer, updated.SecurityEventConfigURL, updated.SecurityEventAudience,
|
||||
updated.TenantContextEndpoint, updated.TenantContextAudience, updated.TenantContextScope,
|
||||
updated.MachineCredentialRef, updated.SessionEncryptionKeyRef, updated.LastTraceID, updated.LastAuditID,
|
||||
))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -296,8 +300,12 @@ func (s *Store) ActivateIdentityRevision(ctx context.Context, id string, expecte
|
||||
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 {
|
||||
if err := tx.QueryRow(ctx, `SELECT revision.tenant_mode='multi_tenant' OR EXISTS(
|
||||
SELECT 1 FROM gateway_tenants tenant
|
||||
WHERE tenant.tenant_key=revision.local_tenant_key AND tenant.status='active'
|
||||
)
|
||||
FROM gateway_identity_configuration_revisions revision
|
||||
WHERE revision.id=$1::uuid`, id).Scan(&tenantExists); err != nil {
|
||||
return identity.Revision{}, false, err
|
||||
}
|
||||
if !tenantExists {
|
||||
@@ -428,11 +436,12 @@ func scanIdentityRevision(row scanner) (identity.Revision, error) {
|
||||
var state string
|
||||
var scopes, capabilities []byte
|
||||
if err := row.Scan(
|
||||
&revision.ID, &state, &revision.SchemaVersion, &revision.AuthCenterURL, &revision.Issuer, &revision.TenantID,
|
||||
&revision.ID, &state, &revision.SchemaVersion, &revision.TenantMode, &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.TenantContextEndpoint, &revision.TenantContextAudience, &revision.TenantContextScope,
|
||||
&revision.MachineCredentialRef, &revision.SessionEncryptionKeyRef, &revision.SessionIdleSeconds,
|
||||
&revision.SessionAbsoluteSeconds, &revision.SessionRefreshSeconds, &revision.Version, &revision.LastErrorCategory,
|
||||
&revision.LastTraceID, &revision.LastAuditID, &revision.ValidatedAt, &revision.ActivatedAt, &revision.SupersededAt,
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (s *Store) resolveOrProvisionOIDCMultiTenantUser(ctx context.Context, input ResolveOrProvisionOIDCUserInput) (ResolveOrProvisionOIDCUserResult, error) {
|
||||
if input.Issuer == "" || uuid.Validate(input.ApplicationID) != nil || uuid.Validate(input.TenantID) != nil ||
|
||||
input.Subject == "" || input.TenantMetadataStatus != "synced" && input.TenantMetadataStatus != "metadata_pending" {
|
||||
return ResolveOrProvisionOIDCUserResult{}, errors.New("invalid multi-tenant OIDC user projection input")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
bindingID, gatewayTenantID, tenantKey, groupID, groupKey, accessStatus, err :=
|
||||
loadOIDCTenantBinding(ctx, tx, input)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
if !input.ProvisioningEnabled {
|
||||
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserNotProvisioned
|
||||
}
|
||||
bindingID, gatewayTenantID, tenantKey, groupID, groupKey, err =
|
||||
createOIDCTenantBinding(ctx, tx, input)
|
||||
accessStatus = "active"
|
||||
}
|
||||
if err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
if accessStatus != "active" {
|
||||
if input.TenantMetadataStatus != "synced" {
|
||||
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCTenantUnavailable
|
||||
}
|
||||
tag, reactivateErr := tx.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings
|
||||
SET access_status='active',metadata_status='synced',last_error_category=NULL,updated_at=now()
|
||||
WHERE id=$1::uuid AND access_status='disabled'`, bindingID)
|
||||
if reactivateErr != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, reactivateErr
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCTenantUnavailable
|
||||
}
|
||||
accessStatus = "active"
|
||||
}
|
||||
if input.TenantMetadataStatus == "synced" {
|
||||
if _, err := tx.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings SET
|
||||
metadata_status='synced',display_name=$2,slug=$3,metadata_version=NULLIF($4,''),
|
||||
metadata_etag=NULLIF($5,''),metadata_updated_at=$6,
|
||||
last_sync_at=now(),next_sync_at=now()+interval '15 minutes',sync_failure_count=0,last_error_category=NULL,updated_at=now()
|
||||
WHERE id=$1::uuid AND access_status='active'`,
|
||||
bindingID, input.TenantName, input.TenantSlug, input.TenantMetadataVersion,
|
||||
input.TenantMetadataETag, nullableOIDCTenantMetadataTime(input.TenantMetadataUpdatedAt),
|
||||
); err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE gateway_tenants SET name=$2,synced_at=now(),source_updated_at=$3,updated_at=now()
|
||||
WHERE id=$1::uuid AND source='oidc_v2' AND status='active' AND deleted_at IS NULL`,
|
||||
gatewayTenantID, input.TenantName, input.TenantMetadataUpdatedAt,
|
||||
); err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
}
|
||||
|
||||
user, userBindingID, err := loadOIDCMultiTenantUser(ctx, tx, bindingID, input.Subject)
|
||||
created := false
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
if !input.ProvisioningEnabled {
|
||||
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserNotProvisioned
|
||||
}
|
||||
user, userBindingID, created, err = s.createOIDCMultiTenantUser(
|
||||
ctx, tx, bindingID, gatewayTenantID, tenantKey, groupID, input,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
if user.Status != "active" {
|
||||
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserDisabled
|
||||
}
|
||||
rolesJSON, _ := json.Marshal(input.Roles)
|
||||
user, err = scanUser(tx.QueryRow(ctx, `UPDATE gateway_users SET
|
||||
username=COALESCE(NULLIF($2,''),username),roles=$3::jsonb,last_login_at=now(),synced_at=now(),source_updated_at=now(),updated_at=now()
|
||||
WHERE id=$1::uuid AND source='oidc_v2' AND status='active' AND deleted_at IS NULL
|
||||
RETURNING `+userColumns, user.ID, input.Username, string(rolesJSON)))
|
||||
if err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
var auditID string
|
||||
if created {
|
||||
subjectHash := sha256.Sum256([]byte(input.Subject))
|
||||
audit, err := s.RecordAuditLogTx(ctx, tx, AuditLogInput{
|
||||
Category: "identity", Action: "identity.oidc_user.provisioned",
|
||||
ActorGatewayUserID: user.ID, ActorUsername: user.Username, ActorSource: "oidc", ActorRoles: user.Roles,
|
||||
TargetType: "gateway_user", TargetID: user.ID, TargetGatewayUserID: user.ID,
|
||||
TargetGatewayTenantID: user.GatewayTenantID, RequestIP: input.RequestIP, UserAgent: input.UserAgent,
|
||||
AfterState: map[string]any{"source": "oidc_v2", "tenantKey": user.TenantKey, "userGroupId": user.DefaultUserGroupID},
|
||||
Metadata: map[string]any{
|
||||
"provisioningMode": "oidc-multi-tenant-jit",
|
||||
"externalSubjectHash": hex.EncodeToString(subjectHash[:])[:16],
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
auditID = audit.ID
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
return ResolveOrProvisionOIDCUserResult{
|
||||
User: multiTenantAuthUser(user, groupKey, input, userBindingID),
|
||||
Created: created, AuditID: auditID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadOIDCTenantBinding(ctx context.Context, tx pgx.Tx, input ResolveOrProvisionOIDCUserInput) (string, string, string, string, string, string, error) {
|
||||
var bindingID, gatewayTenantID, tenantKey, groupID, groupKey, accessStatus string
|
||||
err := tx.QueryRow(ctx, `SELECT binding.id::text,tenant.id::text,tenant.tenant_key,
|
||||
tenant.default_user_group_id::text,group_record.group_key,binding.access_status
|
||||
FROM gateway_oidc_tenant_bindings binding
|
||||
JOIN gateway_tenants tenant ON tenant.id=binding.gateway_tenant_id
|
||||
JOIN gateway_user_groups group_record ON group_record.id=tenant.default_user_group_id
|
||||
WHERE binding.issuer=$1 AND binding.application_id=$2 AND binding.external_tenant_id=$3
|
||||
AND tenant.status='active' AND tenant.deleted_at IS NULL AND group_record.status='active'
|
||||
FOR UPDATE OF binding,tenant`,
|
||||
input.Issuer, input.ApplicationID, input.TenantID,
|
||||
).Scan(&bindingID, &gatewayTenantID, &tenantKey, &groupID, &groupKey, &accessStatus)
|
||||
return bindingID, gatewayTenantID, tenantKey, groupID, groupKey, accessStatus, err
|
||||
}
|
||||
|
||||
func createOIDCTenantBinding(ctx context.Context, tx pgx.Tx, input ResolveOrProvisionOIDCUserInput) (string, string, string, string, string, error) {
|
||||
var groupID, groupKey string
|
||||
if err := tx.QueryRow(ctx, `SELECT id::text,group_key FROM gateway_user_groups
|
||||
WHERE group_key='default' AND status='active'`).Scan(&groupID, &groupKey); err != nil {
|
||||
return "", "", "", "", "", ErrOIDCTenantUnavailable
|
||||
}
|
||||
tenantKey := deriveOIDCMultiTenantKey(input.Issuer, input.ApplicationID, input.TenantID)
|
||||
name := input.TenantName
|
||||
if name == "" {
|
||||
name = "认证中心租户 " + strings.ReplaceAll(input.TenantID, "-", "")[:8]
|
||||
}
|
||||
metadata, _ := json.Marshal(map[string]any{"provisioningMode": "oidc-multi-tenant-jit", "metadataStatus": input.TenantMetadataStatus})
|
||||
var gatewayTenantID string
|
||||
err := tx.QueryRow(ctx, `INSERT INTO gateway_tenants(
|
||||
tenant_key,source,external_tenant_id,name,default_user_group_id,metadata,status,synced_at,source_updated_at
|
||||
) VALUES($1,'oidc_v2',NULL,$2,$3::uuid,$4::jsonb,'active',
|
||||
CASE WHEN $5='synced' THEN now() ELSE NULL END,$6)
|
||||
ON CONFLICT(tenant_key) DO UPDATE SET updated_at=gateway_tenants.updated_at
|
||||
RETURNING id::text`, tenantKey, name, groupID, string(metadata), input.TenantMetadataStatus, nullableOIDCTenantMetadataTime(input.TenantMetadataUpdatedAt)).Scan(&gatewayTenantID)
|
||||
if err != nil {
|
||||
return "", "", "", "", "", err
|
||||
}
|
||||
var bindingID string
|
||||
err = tx.QueryRow(ctx, `INSERT INTO gateway_oidc_tenant_bindings(
|
||||
issuer,application_id,external_tenant_id,gateway_tenant_id,access_status,metadata_status,
|
||||
display_name,slug,metadata_version,metadata_etag,metadata_updated_at,last_sync_at,next_sync_at
|
||||
) VALUES($1,$2,$3,$4::uuid,'active',$5,NULLIF($6,''),NULLIF($7,''),NULLIF($8,''),NULLIF($9,''),
|
||||
$10,
|
||||
CASE WHEN $5='synced' THEN now() ELSE NULL END,
|
||||
CASE WHEN $5='synced' THEN now()+interval '15 minutes' ELSE now()+interval '30 seconds' END)
|
||||
ON CONFLICT(issuer,application_id,external_tenant_id) DO UPDATE SET updated_at=gateway_oidc_tenant_bindings.updated_at
|
||||
RETURNING id::text`,
|
||||
input.Issuer, input.ApplicationID, input.TenantID, gatewayTenantID, input.TenantMetadataStatus,
|
||||
input.TenantName, input.TenantSlug, input.TenantMetadataVersion, input.TenantMetadataETag,
|
||||
nullableOIDCTenantMetadataTime(input.TenantMetadataUpdatedAt),
|
||||
).Scan(&bindingID)
|
||||
return bindingID, gatewayTenantID, tenantKey, groupID, groupKey, err
|
||||
}
|
||||
|
||||
func loadOIDCMultiTenantUser(ctx context.Context, tx pgx.Tx, bindingID, subject string) (GatewayUser, string, error) {
|
||||
var userBindingID, gatewayUserID string
|
||||
if err := tx.QueryRow(ctx, `SELECT binding.id::text,binding.gateway_user_id::text
|
||||
FROM gateway_oidc_user_bindings binding
|
||||
JOIN gateway_users ON gateway_users.id=binding.gateway_user_id
|
||||
WHERE binding.tenant_binding_id=$1::uuid AND binding.subject=$2
|
||||
AND gateway_users.deleted_at IS NULL
|
||||
FOR UPDATE OF binding,gateway_users`, bindingID, subject).Scan(&userBindingID, &gatewayUserID); err != nil {
|
||||
return GatewayUser{}, "", err
|
||||
}
|
||||
user, err := scanUser(tx.QueryRow(ctx, `SELECT `+userColumns+`
|
||||
FROM gateway_users WHERE id=$1::uuid`, gatewayUserID))
|
||||
return user, userBindingID, err
|
||||
}
|
||||
|
||||
func (s *Store) createOIDCMultiTenantUser(ctx context.Context, tx pgx.Tx, bindingID, gatewayTenantID, tenantKey, groupID string, input ResolveOrProvisionOIDCUserInput) (GatewayUser, string, bool, error) {
|
||||
userKey := deriveOIDCMultiTenantUserKey(input.Issuer, input.ApplicationID, input.TenantID, input.Subject)
|
||||
username := input.Username
|
||||
if username == "" {
|
||||
username = "oidc-" + strings.TrimPrefix(userKey, "oidc2:")[:12]
|
||||
}
|
||||
rolesJSON, _ := json.Marshal(input.Roles)
|
||||
metadata, _ := json.Marshal(map[string]any{"provisioningMode": "oidc-multi-tenant-jit"})
|
||||
user, err := scanUser(tx.QueryRow(ctx, `INSERT INTO gateway_users(
|
||||
user_key,source,external_user_id,username,gateway_tenant_id,tenant_id,tenant_key,
|
||||
default_user_group_id,roles,auth_profile,metadata,status,last_login_at,synced_at,source_updated_at
|
||||
) VALUES($1,'oidc_v2',NULL,$2,$3::uuid,$4,$5,$6::uuid,$7::jsonb,'{}'::jsonb,$8::jsonb,
|
||||
'active',now(),now(),now())
|
||||
ON CONFLICT(user_key) DO UPDATE SET updated_at=gateway_users.updated_at
|
||||
RETURNING `+userColumns,
|
||||
userKey, username, gatewayTenantID, input.TenantID, tenantKey, groupID, string(rolesJSON), string(metadata),
|
||||
))
|
||||
if err != nil {
|
||||
return GatewayUser{}, "", false, err
|
||||
}
|
||||
var userBindingID string
|
||||
tag, err := tx.Exec(ctx, `INSERT INTO gateway_oidc_user_bindings(tenant_binding_id,subject,gateway_user_id)
|
||||
VALUES($1::uuid,$2,$3::uuid) ON CONFLICT(tenant_binding_id,subject) DO NOTHING`,
|
||||
bindingID, input.Subject, user.ID)
|
||||
if err != nil {
|
||||
return GatewayUser{}, "", false, err
|
||||
}
|
||||
created := tag.RowsAffected() == 1
|
||||
if err := tx.QueryRow(ctx, `SELECT id::text FROM gateway_oidc_user_bindings
|
||||
WHERE tenant_binding_id=$1::uuid AND subject=$2`, bindingID, input.Subject).Scan(&userBindingID); err != nil {
|
||||
return GatewayUser{}, "", false, err
|
||||
}
|
||||
if _, err := s.ensureWalletAccount(ctx, tx, user.ID, "resource"); err != nil {
|
||||
return GatewayUser{}, "", false, err
|
||||
}
|
||||
return user, userBindingID, created, nil
|
||||
}
|
||||
|
||||
func deriveOIDCMultiTenantKey(issuer, applicationID, tenantID string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimRight(issuer, "/") + "\x00" + applicationID + "\x00" + tenantID))
|
||||
return fmt.Sprintf("oidc2-tenant-%x", sum[:16])
|
||||
}
|
||||
|
||||
func nullableOIDCTenantMetadataTime(value time.Time) any {
|
||||
if value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func deriveOIDCMultiTenantUserKey(issuer, applicationID, tenantID, subject string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimRight(issuer, "/") + "\x00" + applicationID + "\x00" + tenantID + "\x00" + subject))
|
||||
return fmt.Sprintf("oidc2:%x", sum)
|
||||
}
|
||||
|
||||
func multiTenantAuthUser(user GatewayUser, groupKey string, input ResolveOrProvisionOIDCUserInput, userBindingID string) *auth.User {
|
||||
result := authUserFromOIDCProjection(user, groupKey)
|
||||
result.ID = input.Subject
|
||||
result.TenantName = input.TenantName
|
||||
result.Issuer = input.Issuer
|
||||
result.ApplicationID = input.ApplicationID
|
||||
result.OIDCClientID = input.OIDCClientID
|
||||
result.OIDCUserBindingID = userBindingID
|
||||
return result
|
||||
}
|
||||
@@ -15,7 +15,12 @@ type OIDCSession struct {
|
||||
SessionTokenHash []byte
|
||||
GatewayUserID string
|
||||
GatewayTenantID string
|
||||
OIDCUserBindingID string
|
||||
ExternalUserID string
|
||||
Issuer string
|
||||
ApplicationID string
|
||||
TenantID string
|
||||
OIDCClientID string
|
||||
UserStatus string
|
||||
UserDeleted bool
|
||||
TokenCiphertext []byte
|
||||
@@ -34,6 +39,11 @@ type CreateOIDCSessionInput struct {
|
||||
SessionTokenHash []byte
|
||||
GatewayUserID string
|
||||
GatewayTenantID string
|
||||
OIDCUserBindingID string
|
||||
OIDCClientID string
|
||||
Issuer string
|
||||
ApplicationID string
|
||||
TenantID string
|
||||
TokenCiphertext []byte
|
||||
AccessTokenExpiresAt time.Time
|
||||
LastSeenAt time.Time
|
||||
@@ -46,12 +56,27 @@ func (s *Store) CreateOIDCSession(ctx context.Context, input CreateOIDCSessionIn
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO gateway_oidc_sessions (
|
||||
session_token_hash, gateway_user_id, gateway_tenant_id, token_ciphertext,
|
||||
access_token_expires_at, last_seen_at, idle_expires_at, absolute_expires_at
|
||||
access_token_expires_at, last_seen_at, idle_expires_at, absolute_expires_at,
|
||||
oidc_user_binding_id, oidc_client_id
|
||||
)
|
||||
VALUES ($1, $2::uuid, $3::uuid, $4, $5, $6, $7, $8)
|
||||
SELECT $1, u.id, $3::uuid, $4, $5, $6, $7, $8, NULLIF($9, '')::uuid, NULLIF($10, '')
|
||||
FROM gateway_users u
|
||||
LEFT JOIN gateway_oidc_user_bindings ub ON ub.id = NULLIF($9, '')::uuid
|
||||
LEFT JOIN gateway_oidc_tenant_bindings tb ON tb.id = ub.tenant_binding_id
|
||||
WHERE u.id = $2::uuid
|
||||
AND u.gateway_tenant_id = $3::uuid
|
||||
AND ($9 = '' OR (
|
||||
ub.gateway_user_id = u.id
|
||||
AND tb.gateway_tenant_id = u.gateway_tenant_id
|
||||
AND tb.issuer = $11
|
||||
AND tb.application_id = $12
|
||||
AND tb.external_tenant_id = $13
|
||||
))
|
||||
RETURNING id::text`,
|
||||
input.SessionTokenHash, input.GatewayUserID, input.GatewayTenantID, input.TokenCiphertext,
|
||||
input.AccessTokenExpiresAt, input.LastSeenAt, input.IdleExpiresAt, input.AbsoluteExpiresAt,
|
||||
input.OIDCUserBindingID, input.OIDCClientID,
|
||||
input.Issuer, input.ApplicationID, input.TenantID,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return OIDCSession{}, err
|
||||
@@ -64,6 +89,8 @@ func (s *Store) FindOIDCSessionByHash(ctx context.Context, hash []byte) (OIDCSes
|
||||
SELECT `+oidcSessionColumns+`
|
||||
FROM gateway_oidc_sessions s
|
||||
JOIN gateway_users u ON u.id = s.gateway_user_id
|
||||
LEFT JOIN gateway_oidc_user_bindings ub ON ub.id = s.oidc_user_binding_id
|
||||
LEFT JOIN gateway_oidc_tenant_bindings tb ON tb.id = ub.tenant_binding_id
|
||||
WHERE s.session_token_hash = $1`, hash))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return OIDCSession{}, ErrOIDCSessionNotFound
|
||||
@@ -138,7 +165,9 @@ WHERE idle_expires_at <= $1 OR absolute_expires_at <= $1`, now)
|
||||
|
||||
const oidcSessionColumns = `
|
||||
s.id::text, s.session_token_hash, s.gateway_user_id::text, s.gateway_tenant_id::text,
|
||||
COALESCE(u.external_user_id, ''), u.status, u.deleted_at IS NOT NULL,
|
||||
COALESCE(s.oidc_user_binding_id::text, ''), COALESCE(ub.subject, u.external_user_id, ''),
|
||||
COALESCE(tb.issuer, ''), COALESCE(tb.application_id, ''), COALESCE(tb.external_tenant_id, ''),
|
||||
COALESCE(s.oidc_client_id, ''), u.status, u.deleted_at IS NOT NULL,
|
||||
s.token_ciphertext, s.access_token_expires_at, s.last_seen_at, s.idle_expires_at,
|
||||
s.absolute_expires_at, s.refresh_version, COALESCE(s.refresh_lock_id::text, ''),
|
||||
COALESCE(s.refresh_lock_until, 'epoch'::timestamptz), s.created_at, s.updated_at`
|
||||
@@ -147,7 +176,8 @@ func scanOIDCSession(row pgx.Row) (OIDCSession, error) {
|
||||
var item OIDCSession
|
||||
err := row.Scan(
|
||||
&item.ID, &item.SessionTokenHash, &item.GatewayUserID, &item.GatewayTenantID,
|
||||
&item.ExternalUserID, &item.UserStatus, &item.UserDeleted, &item.TokenCiphertext,
|
||||
&item.OIDCUserBindingID, &item.ExternalUserID, &item.Issuer, &item.ApplicationID,
|
||||
&item.TenantID, &item.OIDCClientID, &item.UserStatus, &item.UserDeleted, &item.TokenCiphertext,
|
||||
&item.AccessTokenExpiresAt, &item.LastSeenAt, &item.IdleExpiresAt, &item.AbsoluteExpiresAt,
|
||||
&item.RefreshVersion, &item.RefreshLockID, &item.RefreshLockUntil, &item.CreatedAt, &item.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type OIDCTenantBindingSyncTarget struct {
|
||||
ID string
|
||||
ExternalTenantID string
|
||||
ETag string
|
||||
FailureCount int
|
||||
}
|
||||
|
||||
type OIDCTenantBindingContext struct {
|
||||
ID string
|
||||
AccessStatus string
|
||||
MetadataStatus string
|
||||
DisplayName string
|
||||
Slug string
|
||||
Version string
|
||||
ETag string
|
||||
MetadataUpdatedAt time.Time
|
||||
NextSyncAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) DueOIDCTenantBindingSyncs(ctx context.Context, issuer, applicationID string, limit int) ([]OIDCTenantBindingSyncTarget, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `SELECT id::text,external_tenant_id,COALESCE(metadata_etag,''),sync_failure_count
|
||||
FROM gateway_oidc_tenant_bindings
|
||||
WHERE issuer=$1 AND application_id=$2 AND access_status='active' AND next_sync_at <= now()
|
||||
ORDER BY next_sync_at,id
|
||||
LIMIT $3`, strings.TrimRight(strings.TrimSpace(issuer), "/"), strings.TrimSpace(applicationID), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]OIDCTenantBindingSyncTarget, 0)
|
||||
for rows.Next() {
|
||||
var item OIDCTenantBindingSyncTarget
|
||||
if err := rows.Scan(&item.ID, &item.ExternalTenantID, &item.ETag, &item.FailureCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) OIDCTenantBindingContext(ctx context.Context, issuer, applicationID, tenantID string) (OIDCTenantBindingContext, error) {
|
||||
var item OIDCTenantBindingContext
|
||||
err := s.pool.QueryRow(ctx, `SELECT id::text,access_status,metadata_status,COALESCE(display_name,''),
|
||||
COALESCE(slug,''),COALESCE(metadata_version,''),COALESCE(metadata_etag,''),
|
||||
COALESCE(metadata_updated_at,'epoch'::timestamptz),next_sync_at
|
||||
FROM gateway_oidc_tenant_bindings
|
||||
WHERE issuer=$1 AND application_id=$2 AND external_tenant_id=$3`,
|
||||
strings.TrimRight(strings.TrimSpace(issuer), "/"), strings.TrimSpace(applicationID), strings.TrimSpace(tenantID),
|
||||
).Scan(&item.ID, &item.AccessStatus, &item.MetadataStatus, &item.DisplayName, &item.Slug,
|
||||
&item.Version, &item.ETag, &item.MetadataUpdatedAt, &item.NextSyncAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return OIDCTenantBindingContext{}, ErrOIDCTenantUnavailable
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Store) ApplyOIDCTenantBindingSync(ctx context.Context, bindingID string, tenant identity.TenantContext, unchanged bool, now time.Time) error {
|
||||
if uuid.Validate(bindingID) != nil {
|
||||
return errors.New("OIDC tenant binding id is invalid")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if unchanged {
|
||||
tag, err := tx.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings
|
||||
SET last_sync_at=$2::timestamptz,next_sync_at=$2::timestamptz+interval '15 minutes',sync_failure_count=0,
|
||||
last_error_category=NULL,updated_at=now()
|
||||
WHERE id=$1::uuid AND access_status='active'`, bindingID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrOIDCTenantUnavailable
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
if !tenant.Active() || strings.TrimSpace(tenant.DisplayName) == "" || strings.TrimSpace(tenant.Slug) == "" {
|
||||
return errors.New("OIDC tenant context is inactive or incomplete")
|
||||
}
|
||||
var gatewayTenantID string
|
||||
err = tx.QueryRow(ctx, `UPDATE gateway_oidc_tenant_bindings
|
||||
SET metadata_status='synced',display_name=$2,slug=$3,metadata_version=$4,metadata_etag=NULLIF($5,''),
|
||||
metadata_updated_at=$6,last_sync_at=$7::timestamptz,
|
||||
next_sync_at=$7::timestamptz+interval '15 minutes',
|
||||
sync_failure_count=0,last_error_category=NULL,updated_at=now()
|
||||
WHERE id=$1::uuid AND access_status='active'
|
||||
RETURNING gateway_tenant_id::text`,
|
||||
bindingID, tenant.DisplayName, tenant.Slug, tenant.Version, tenant.ETag, tenant.UpdatedAt, now,
|
||||
).Scan(&gatewayTenantID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrOIDCTenantUnavailable
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE gateway_tenants
|
||||
SET name=$2,synced_at=$3,source_updated_at=$4,updated_at=now()
|
||||
WHERE id=$1::uuid AND source='oidc_v2' AND status='active' AND deleted_at IS NULL`,
|
||||
gatewayTenantID, tenant.DisplayName, now, tenant.UpdatedAt,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) RejectOIDCTenantBinding(ctx context.Context, bindingID, category string, now time.Time) error {
|
||||
if uuid.Validate(bindingID) != nil {
|
||||
return errors.New("OIDC tenant binding id is invalid")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
tag, err := tx.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings
|
||||
SET access_status='disabled',metadata_status='rejected',last_sync_at=$2::timestamptz,
|
||||
next_sync_at=$2::timestamptz+interval '15 minutes',
|
||||
last_error_category=$3,updated_at=now()
|
||||
WHERE id=$1::uuid`, bindingID, now, limitOIDCTenantContextCategory(category))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrOIDCTenantUnavailable
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM gateway_oidc_sessions session
|
||||
USING gateway_oidc_user_bindings user_binding
|
||||
WHERE session.oidc_user_binding_id=user_binding.id AND user_binding.tenant_binding_id=$1::uuid`,
|
||||
bindingID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) FailOIDCTenantBindingSync(ctx context.Context, bindingID, category string, next time.Time) error {
|
||||
if uuid.Validate(bindingID) != nil {
|
||||
return errors.New("OIDC tenant binding id is invalid")
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings
|
||||
SET sync_failure_count=sync_failure_count+1,next_sync_at=$2,last_error_category=$3,updated_at=now()
|
||||
WHERE id=$1::uuid AND access_status='active'`, bindingID, next, limitOIDCTenantContextCategory(category))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrOIDCTenantUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func limitOIDCTenantContextCategory(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) > 64 {
|
||||
return value[:64]
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -20,15 +21,24 @@ var (
|
||||
)
|
||||
|
||||
type ResolveOrProvisionOIDCUserInput struct {
|
||||
Issuer string
|
||||
Subject string
|
||||
Username string
|
||||
Roles []string
|
||||
TenantID string
|
||||
GatewayTenantKey string
|
||||
ProvisioningEnabled bool
|
||||
RequestIP string
|
||||
UserAgent string
|
||||
Issuer string
|
||||
ApplicationID string
|
||||
Subject string
|
||||
Username string
|
||||
Roles []string
|
||||
TenantID string
|
||||
TenantMode string
|
||||
TenantName string
|
||||
TenantSlug string
|
||||
TenantMetadataStatus string
|
||||
TenantMetadataVersion string
|
||||
TenantMetadataETag string
|
||||
TenantMetadataUpdatedAt time.Time
|
||||
OIDCClientID string
|
||||
GatewayTenantKey string
|
||||
ProvisioningEnabled bool
|
||||
RequestIP string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
type ResolveOrProvisionOIDCUserResult struct {
|
||||
@@ -48,6 +58,9 @@ type oidcUserProjection struct {
|
||||
|
||||
func (s *Store) ResolveOrProvisionOIDCUser(ctx context.Context, input ResolveOrProvisionOIDCUserInput) (ResolveOrProvisionOIDCUserResult, error) {
|
||||
input = normalizeOIDCUserInput(input)
|
||||
if input.TenantMode == "multi_tenant" {
|
||||
return s.resolveOrProvisionOIDCMultiTenantUser(ctx, input)
|
||||
}
|
||||
if input.Issuer == "" || input.Subject == "" || input.TenantID == "" {
|
||||
return ResolveOrProvisionOIDCUserResult{}, errors.New("invalid OIDC user projection input")
|
||||
}
|
||||
@@ -296,6 +309,13 @@ func normalizeOIDCUserInput(input ResolveOrProvisionOIDCUserInput) ResolveOrProv
|
||||
input.Subject = strings.TrimSpace(input.Subject)
|
||||
input.Username = strings.TrimSpace(input.Username)
|
||||
input.TenantID = strings.TrimSpace(input.TenantID)
|
||||
input.ApplicationID = strings.TrimSpace(input.ApplicationID)
|
||||
input.TenantMode = strings.TrimSpace(input.TenantMode)
|
||||
input.TenantName = strings.TrimSpace(input.TenantName)
|
||||
input.TenantSlug = strings.TrimSpace(input.TenantSlug)
|
||||
input.TenantMetadataStatus = strings.TrimSpace(input.TenantMetadataStatus)
|
||||
input.TenantMetadataVersion = strings.TrimSpace(input.TenantMetadataVersion)
|
||||
input.TenantMetadataETag = strings.TrimSpace(input.TenantMetadataETag)
|
||||
input.GatewayTenantKey = strings.TrimSpace(input.GatewayTenantKey)
|
||||
input.RequestIP = strings.TrimSpace(input.RequestIP)
|
||||
input.UserAgent = strings.TrimSpace(input.UserAgent)
|
||||
|
||||
@@ -11,9 +11,147 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestResolveOrProvisionOIDCMultiTenantUserIsIdempotentIsolatedAndReusable(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 OIDC multi-tenant PostgreSQL integration tests")
|
||||
}
|
||||
ctx := context.Background()
|
||||
applyOIDCJITTestMigrations(t, ctx, databaseURL)
|
||||
db, err := Connect(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
issuer := "https://auth.test.example/issuer/shared"
|
||||
applicationID, tenantA, tenantB, subject := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
|
||||
input := func(tenantID, name, slug string) ResolveOrProvisionOIDCUserInput {
|
||||
return ResolveOrProvisionOIDCUserInput{
|
||||
Issuer: issuer, ApplicationID: applicationID, Subject: subject, Username: "shared-subject",
|
||||
Roles: []string{"basic"}, TenantID: tenantID, TenantMode: "multi_tenant",
|
||||
TenantName: name, TenantSlug: slug, TenantMetadataStatus: "synced",
|
||||
TenantMetadataVersion: "v1", TenantMetadataETag: `"v1"`,
|
||||
TenantMetadataUpdatedAt: time.Unix(1_780_000_000, 0).UTC(),
|
||||
OIDCClientID: "gateway-browser", ProvisioningEnabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
const concurrentLogins = 8
|
||||
results := make([]ResolveOrProvisionOIDCUserResult, concurrentLogins)
|
||||
errs := make([]error, concurrentLogins)
|
||||
var wait sync.WaitGroup
|
||||
for index := range concurrentLogins {
|
||||
wait.Add(1)
|
||||
go func(index int) {
|
||||
defer wait.Done()
|
||||
results[index], errs[index] = db.ResolveOrProvisionOIDCUser(ctx, input(tenantA, "Tenant A", "tenant-a"))
|
||||
}(index)
|
||||
}
|
||||
wait.Wait()
|
||||
var userA *auth.User
|
||||
created := 0
|
||||
for index, result := range results {
|
||||
if errs[index] != nil || result.User == nil {
|
||||
t.Fatalf("tenant A login %d result=%#v error=%v", index, result, errs[index])
|
||||
}
|
||||
if userA == nil {
|
||||
userA = result.User
|
||||
}
|
||||
if result.User.GatewayUserID != userA.GatewayUserID || result.User.GatewayTenantID != userA.GatewayTenantID {
|
||||
t.Fatalf("concurrent tenant A projection diverged: first=%#v current=%#v", userA, result.User)
|
||||
}
|
||||
if result.Created {
|
||||
created++
|
||||
}
|
||||
}
|
||||
if created != 1 {
|
||||
t.Fatalf("tenant A created projections=%d", created)
|
||||
}
|
||||
|
||||
resultB, err := db.ResolveOrProvisionOIDCUser(ctx, input(tenantB, "Tenant B", "tenant-b"))
|
||||
if err != nil || resultB.User == nil {
|
||||
t.Fatalf("tenant B projection=%#v error=%v", resultB, err)
|
||||
}
|
||||
userB := resultB.User
|
||||
if userA.GatewayUserID == userB.GatewayUserID || userA.GatewayTenantID == userB.GatewayTenantID ||
|
||||
userA.ID != userB.ID {
|
||||
t.Fatalf("same subject was not isolated by tenant: A=%#v B=%#v", userA, userB)
|
||||
}
|
||||
|
||||
var tenants, users, wallets, audits int
|
||||
if err := db.pool.QueryRow(ctx, `SELECT
|
||||
(SELECT count(*) FROM gateway_oidc_tenant_bindings WHERE application_id=$1 AND external_tenant_id IN ($2,$3)),
|
||||
(SELECT count(*) FROM gateway_oidc_user_bindings binding
|
||||
JOIN gateway_oidc_tenant_bindings tenant ON tenant.id=binding.tenant_binding_id
|
||||
WHERE tenant.application_id=$1 AND binding.subject=$4),
|
||||
(SELECT count(*) FROM gateway_wallet_accounts WHERE gateway_user_id IN ($5::uuid,$6::uuid) AND currency='resource'),
|
||||
(SELECT count(*) FROM gateway_audit_logs WHERE action='identity.oidc_user.provisioned'
|
||||
AND target_gateway_user_id IN ($5::uuid,$6::uuid))`,
|
||||
applicationID, tenantA, tenantB, subject, userA.GatewayUserID, userB.GatewayUserID,
|
||||
).Scan(&tenants, &users, &wallets, &audits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tenants != 2 || users != 2 || wallets != 2 || audits != 2 {
|
||||
t.Fatalf("tenants=%d users=%d wallets=%d audits=%d", tenants, users, wallets, audits)
|
||||
}
|
||||
|
||||
if _, err := db.CreateAPIKey(ctx, CreateAPIKeyInput{Name: "Tenant A only"}, userA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keysB, err := db.ListAPIKeys(ctx, userB)
|
||||
if err != nil || len(keysB) != 0 {
|
||||
t.Fatalf("tenant B observed tenant A API keys: keys=%#v error=%v", keysB, err)
|
||||
}
|
||||
if _, err := db.CreateTask(ctx, CreateTaskInput{
|
||||
Kind: "multi-tenant-isolation", Model: "local-fixture", RunMode: "async", Async: true,
|
||||
Request: map[string]any{"prompt": "tenant-a"},
|
||||
}, userA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tasksB, err := db.ListTasks(ctx, userB, TaskListFilter{})
|
||||
if err != nil || len(tasksB.Items) != 0 {
|
||||
t.Fatalf("tenant B observed tenant A tasks: tasks=%#v error=%v", tasksB.Items, err)
|
||||
}
|
||||
|
||||
bindingA, err := db.OIDCTenantBindingContext(ctx, issuer, applicationID, tenantA)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.RejectOIDCTenantBinding(ctx, bindingA.ID, "tenant_application_revoked", time.Now().UTC()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pending := input(tenantA, "", "")
|
||||
pending.TenantMetadataStatus = "metadata_pending"
|
||||
if _, err := db.ResolveOrProvisionOIDCUser(ctx, pending); !errorsIs(err, ErrOIDCTenantUnavailable) {
|
||||
t.Fatalf("disabled binding accepted pending context: %v", err)
|
||||
}
|
||||
reactivated, err := db.ResolveOrProvisionOIDCUser(ctx, input(tenantA, "Tenant A restored", "tenant-a"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reactivated.User.GatewayTenantID != userA.GatewayTenantID || reactivated.User.GatewayUserID != userA.GatewayUserID {
|
||||
t.Fatalf("reassignment did not reuse local projection: before=%#v after=%#v", userA, reactivated.User)
|
||||
}
|
||||
if err := db.ApplyOIDCTenantBindingSync(ctx, bindingA.ID, identity.TenantContext{
|
||||
ApplicationID: applicationID, TenantID: tenantA, DisplayName: "Tenant A renamed", Slug: "tenant-a",
|
||||
TenantStatus: "active", TenantApplicationStatus: "active", Version: "v2", ETag: `"v2"`,
|
||||
UpdatedAt: time.Unix(1_780_000_100, 0).UTC(),
|
||||
}, false, time.Now().UTC()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
synced, err := db.OIDCTenantBindingContext(ctx, issuer, applicationID, tenantA)
|
||||
if err != nil || synced.DisplayName != "Tenant A renamed" || synced.Version != "v2" {
|
||||
t.Fatalf("synced tenant context=%#v error=%v", synced, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOrProvisionOIDCUserLifecycleAndConcurrency(t *testing.T) {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
|
||||
@@ -16,6 +16,8 @@ type ApplySessionRevokedInput struct {
|
||||
JTI string
|
||||
TransactionID string
|
||||
SubjectIssuer string
|
||||
ApplicationID string
|
||||
SubjectType string
|
||||
TenantID string
|
||||
Subject string
|
||||
EventTimestamp time.Time
|
||||
@@ -36,6 +38,9 @@ type SecurityEventEvaluation struct {
|
||||
}
|
||||
|
||||
func (s *Store) ApplySessionRevoked(ctx context.Context, input ApplySessionRevokedInput) (ApplySecurityEventResult, error) {
|
||||
if input.SubjectType == "" {
|
||||
input.SubjectType = "principal"
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return ApplySecurityEventResult{}, err
|
||||
@@ -44,13 +49,15 @@ func (s *Store) ApplySessionRevoked(ctx context.Context, input ApplySessionRevok
|
||||
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
|
||||
issuer, jti, audience, event_type, transaction_id, tenant_id, subject_hash, event_timestamp,
|
||||
application_id, subject_type
|
||||
)
|
||||
VALUES ($1, $2::uuid, $3, $4, NULLIF($5, ''), $6, $7, $8)
|
||||
VALUES ($1, $2::uuid, $3, $4, NULLIF($5, ''), $6, $7, $8, NULLIF($9, ''), $10)
|
||||
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,
|
||||
input.ApplicationID, input.SubjectType,
|
||||
)
|
||||
if err != nil {
|
||||
return ApplySecurityEventResult{}, err
|
||||
@@ -64,25 +71,56 @@ ON CONFLICT (issuer, jti) DO NOTHING`,
|
||||
|
||||
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
|
||||
INSERT INTO gateway_oidc_revocation_watermarks (
|
||||
issuer, application_id, tenant_id, subject_type, subject, revoked_at, source_jti
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::uuid)
|
||||
ON CONFLICT (issuer, application_id, tenant_id, subject_type, 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)
|
||||
RETURNING revoked_at`, input.SubjectIssuer, input.ApplicationID, input.TenantID, input.SubjectType,
|
||||
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, `
|
||||
switch {
|
||||
case input.ApplicationID != "" && input.SubjectType == "principal":
|
||||
tag, err = tx.Exec(ctx, `
|
||||
DELETE FROM gateway_oidc_sessions session
|
||||
USING gateway_oidc_user_bindings user_binding,gateway_oidc_tenant_bindings tenant_binding
|
||||
WHERE session.oidc_user_binding_id=user_binding.id
|
||||
AND user_binding.tenant_binding_id=tenant_binding.id
|
||||
AND tenant_binding.issuer=$1
|
||||
AND tenant_binding.application_id=$2
|
||||
AND tenant_binding.external_tenant_id=$3
|
||||
AND user_binding.subject=$4`, input.SubjectIssuer, input.ApplicationID, input.TenantID, input.Subject)
|
||||
case input.ApplicationID != "" && input.SubjectType == "tenant":
|
||||
tag, err = tx.Exec(ctx, `
|
||||
DELETE FROM gateway_oidc_sessions session
|
||||
USING gateway_oidc_user_bindings user_binding,gateway_oidc_tenant_bindings tenant_binding
|
||||
WHERE session.oidc_user_binding_id=user_binding.id
|
||||
AND user_binding.tenant_binding_id=tenant_binding.id
|
||||
AND tenant_binding.issuer=$1
|
||||
AND tenant_binding.application_id=$2
|
||||
AND tenant_binding.external_tenant_id=$3`, input.SubjectIssuer, input.ApplicationID, input.TenantID)
|
||||
if err == nil {
|
||||
_, err = tx.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings
|
||||
SET access_status='disabled',updated_at=now()
|
||||
WHERE issuer=$1 AND application_id=$2 AND external_tenant_id=$3`,
|
||||
input.SubjectIssuer, input.ApplicationID, input.TenantID)
|
||||
}
|
||||
default:
|
||||
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
|
||||
}
|
||||
@@ -95,7 +133,7 @@ WHERE session.gateway_user_id = gateway_user.id
|
||||
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,
|
||||
"applicationId": input.ApplicationID, "subjectType": input.SubjectType, "initiatingEntity": input.InitiatingEntity,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -314,7 +352,7 @@ WHERE issuer=$1 AND audience=$2 AND mode <> 'introspection_fallback'
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) EvaluateOIDCSecurityEvent(ctx context.Context, transmitterIssuer, audience, subjectIssuer, tenantID, subject string, issuedAt, now time.Time, staleAfter time.Duration) (SecurityEventEvaluation, error) {
|
||||
func (s *Store) EvaluateOIDCSecurityEvent(ctx context.Context, transmitterIssuer, audience, subjectIssuer, applicationID, 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
|
||||
@@ -322,9 +360,17 @@ func (s *Store) EvaluateOIDCSecurityEvent(ctx context.Context, transmitterIssuer
|
||||
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(
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT max(revoked_at) AS revoked_at
|
||||
FROM gateway_oidc_revocation_watermarks
|
||||
WHERE issuer=$3 AND application_id=$4 AND tenant_id=$5
|
||||
AND (
|
||||
(subject_type='principal' AND subject=$6)
|
||||
OR (subject_type='tenant' AND subject=$5)
|
||||
)
|
||||
) watermark ON true
|
||||
WHERE state.issuer=$1 AND state.audience=$2`,
|
||||
transmitterIssuer, audience, subjectIssuer, applicationID, tenantID, subject).Scan(
|
||||
&mode, &streamStatus, &lastVerification, &bootstrapUntil, &createdAt, &revokedAt,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
@@ -55,7 +56,7 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
@@ -77,12 +78,12 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
@@ -97,7 +98,7 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
@@ -114,22 +115,22 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) {
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
@@ -183,3 +184,136 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) {
|
||||
t.Fatalf("rebind discarded security history receipts=%d watermarks=%d", retainedReceipts, retainedWatermarks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationScopedSecurityEventsRevokeOnlyMatchingTenantSessions(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 application-scoped security event integration tests")
|
||||
}
|
||||
ctx := context.Background()
|
||||
applyOIDCJITTestMigrations(t, ctx, databaseURL)
|
||||
db, err := Connect(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
transmitterIssuer := "https://auth.test.example/ssf/" + uuid.NewString()
|
||||
subjectIssuer := "https://auth.test.example/issuer/shared"
|
||||
applicationID, tenantA, tenantB, subject := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
|
||||
projection := func(tenantID string) *auth.User {
|
||||
result, err := db.ResolveOrProvisionOIDCUser(ctx, ResolveOrProvisionOIDCUserInput{
|
||||
Issuer: subjectIssuer, ApplicationID: applicationID, Subject: subject, Username: "shared-user",
|
||||
Roles: []string{"basic"}, TenantID: tenantID, TenantMode: "multi_tenant",
|
||||
TenantName: "Tenant " + tenantID[:8], TenantSlug: "tenant-" + tenantID[:8],
|
||||
TenantMetadataStatus: "synced", TenantMetadataVersion: "1",
|
||||
TenantMetadataUpdatedAt: time.Now().UTC(), OIDCClientID: "gateway-browser", ProvisioningEnabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result.User
|
||||
}
|
||||
userA, userB := projection(tenantA), projection(tenantB)
|
||||
if _, err := db.CreateAPIKey(ctx, CreateAPIKeyInput{Name: "must survive OIDC revocation"}, userA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createSession := func(user *auth.User, marker byte) {
|
||||
now := time.Now().UTC()
|
||||
sessionHash := sha256.Sum256([]byte(uuid.NewString()))
|
||||
if _, err := db.CreateOIDCSession(ctx, CreateOIDCSessionInput{
|
||||
SessionTokenHash: sessionHash[:], GatewayUserID: user.GatewayUserID,
|
||||
GatewayTenantID: user.GatewayTenantID, OIDCUserBindingID: user.OIDCUserBindingID,
|
||||
OIDCClientID: "gateway-browser", Issuer: subjectIssuer, ApplicationID: applicationID,
|
||||
TenantID: user.TenantID, TokenCiphertext: []byte{marker},
|
||||
AccessTokenExpiresAt: now.Add(time.Hour), LastSeenAt: now,
|
||||
IdleExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(2 * time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
createSession(userA, 0xa1)
|
||||
createSession(userB, 0xb1)
|
||||
|
||||
revokedAt := time.Now().UTC().Truncate(time.Second)
|
||||
principalEvent := ApplySessionRevokedInput{
|
||||
Issuer: transmitterIssuer, Audience: "urn:easyai:ssf:receiver:" + applicationID,
|
||||
JTI: uuid.NewString(), TransactionID: uuid.NewString(), SubjectIssuer: subjectIssuer,
|
||||
ApplicationID: applicationID, SubjectType: "principal", TenantID: tenantA, Subject: subject,
|
||||
EventTimestamp: revokedAt, InitiatingEntity: "admin",
|
||||
}
|
||||
result, err := db.ApplySessionRevoked(ctx, principalEvent)
|
||||
if err != nil || result.SessionsDeleted != 1 || !result.WatermarkMoved {
|
||||
t.Fatalf("principal result=%#v error=%v", result, err)
|
||||
}
|
||||
var tenantASessions, tenantBSessions int
|
||||
countSessions := func() {
|
||||
t.Helper()
|
||||
if err := db.pool.QueryRow(ctx, `SELECT
|
||||
(SELECT count(*) FROM gateway_oidc_sessions WHERE gateway_tenant_id=$1::uuid),
|
||||
(SELECT count(*) FROM gateway_oidc_sessions WHERE gateway_tenant_id=$2::uuid)`,
|
||||
userA.GatewayTenantID, userB.GatewayTenantID,
|
||||
).Scan(&tenantASessions, &tenantBSessions); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
countSessions()
|
||||
if tenantASessions != 0 || tenantBSessions != 1 {
|
||||
t.Fatalf("principal revocation sessions A=%d B=%d", tenantASessions, tenantBSessions)
|
||||
}
|
||||
older := principalEvent
|
||||
older.JTI = uuid.NewString()
|
||||
older.EventTimestamp = revokedAt.Add(-time.Minute)
|
||||
result, err = db.ApplySessionRevoked(ctx, older)
|
||||
if err != nil || result.WatermarkMoved || result.SessionsDeleted != 0 {
|
||||
t.Fatalf("older result=%#v error=%v", result, err)
|
||||
}
|
||||
|
||||
createSession(userA, 0xa2)
|
||||
tenantEvent := principalEvent
|
||||
tenantEvent.JTI, tenantEvent.TransactionID = uuid.NewString(), uuid.NewString()
|
||||
tenantEvent.SubjectType, tenantEvent.Subject = "tenant", tenantA
|
||||
tenantEvent.EventTimestamp = revokedAt.Add(time.Minute)
|
||||
result, err = db.ApplySessionRevoked(ctx, tenantEvent)
|
||||
if err != nil || result.SessionsDeleted != 1 || !result.WatermarkMoved {
|
||||
t.Fatalf("tenant result=%#v error=%v", result, err)
|
||||
}
|
||||
countSessions()
|
||||
if tenantASessions != 0 || tenantBSessions != 1 {
|
||||
t.Fatalf("tenant revocation sessions A=%d B=%d", tenantASessions, tenantBSessions)
|
||||
}
|
||||
keysA, err := db.ListAPIKeys(ctx, userA)
|
||||
if err != nil || len(keysA) != 1 {
|
||||
t.Fatalf("tenant revocation changed API keys: keys=%#v error=%v", keysA, err)
|
||||
}
|
||||
var accessStatus string
|
||||
if err := db.pool.QueryRow(ctx, `SELECT access_status FROM gateway_oidc_tenant_bindings
|
||||
WHERE issuer=$1 AND application_id=$2 AND external_tenant_id=$3`,
|
||||
subjectIssuer, applicationID, tenantA,
|
||||
).Scan(&accessStatus); err != nil || accessStatus != "disabled" {
|
||||
t.Fatalf("tenant A access status=%q error=%v", accessStatus, err)
|
||||
}
|
||||
|
||||
streamID := uuid.NewString()
|
||||
audience := tenantEvent.Audience
|
||||
if err := db.EnsureSecurityEventStreamState(ctx, transmitterIssuer, audience, streamID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = db.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state
|
||||
SET stream_status='enabled',mode='push_healthy',last_verification_at=$3,bootstrap_until=$3
|
||||
WHERE issuer=$1 AND audience=$2`, transmitterIssuer, audience, revokedAt.Add(2*time.Minute))
|
||||
evaluationA, err := db.EvaluateOIDCSecurityEvent(
|
||||
ctx, transmitterIssuer, audience, subjectIssuer, applicationID, tenantA, subject,
|
||||
tenantEvent.EventTimestamp, revokedAt.Add(2*time.Minute), 5*time.Minute,
|
||||
)
|
||||
if err != nil || !evaluationA.Revoked {
|
||||
t.Fatalf("tenant A evaluation=%#v error=%v", evaluationA, err)
|
||||
}
|
||||
evaluationB, err := db.EvaluateOIDCSecurityEvent(
|
||||
ctx, transmitterIssuer, audience, subjectIssuer, applicationID, tenantB, subject,
|
||||
tenantEvent.EventTimestamp, revokedAt.Add(2*time.Minute), 5*time.Minute,
|
||||
)
|
||||
if err != nil || evaluationB.Revoked {
|
||||
t.Fatalf("tenant B evaluation=%#v error=%v", evaluationB, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user