diff --git a/.project-docs/30-worklog/tasks/20260813-go-identity-vertical-c4e91a72.md b/.project-docs/30-worklog/tasks/20260813-go-identity-vertical-c4e91a72.md new file mode 100644 index 0000000..29368c5 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260813-go-identity-vertical-c4e91a72.md @@ -0,0 +1,73 @@ +# Task: Implement Go identity authorization vertical slice + +## Identity + +- Task ID: 20260813-go-identity-vertical-c4e91a72 +- Mode: Feature +- Branch: codex/20260813-go-identity-vertical-c4e91a72-go-identity-vertical +- Worktree: /Users/brother7/Documents/AI/NianAIGC-go-identity-c4e91a72 +- Base commit: 716a8031b1f0b322470e180e45dea2fe12fdead3 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Add a language-neutral platform-session authorization matrix that fixes the per-request account, organization, role, client, and `sessionVersion` contract shared by TypeScript and Go. +- Add a deep Go Identity Module whose external seam resolves a signed legacy Cookie into a database-refreshed current session or a typed unauthenticated rejection. +- Add a PostgreSQL Adapter that loads the complete authorization snapshot in one parameterized account/organization query. +- Keep login, password changes, HTTP identity routes, middleware replacement, Docker/ACK manifests, and production traffic ownership outside this slice. + +## Intent And Constraints + +- Follow vertical red-green TDD at the existing signed-session/Identity seam and PostgreSQL Adapter seam. +- Treat Cookie data only as authenticated input; database account role, profile, status, organization state, and session version are authoritative for every resolved request. +- Preserve current legacy compatibility where an absent or zero `sessionVersion` is accepted, while any nonzero mismatch is rejected; the refreshed result always carries the current database version. +- Require every non-super-admin account to belong to an active existing organization; allow an unbound super-admin as the current billing/administration model requires. +- Collapse authentication denials for callers while retaining typed internal rejection reasons for tests and diagnostics; propagate database failures separately. +- Do not expose or route a Go identity endpoint and do not change ACK-001 production behavior in this task. + +## Outcome + +- Added `contracts/auth/platform-session-authorization-v1.json`, a language-neutral 14-case matrix covering database-authoritative claim refresh, platform-client enforcement, active/disabled accounts, exact platform roles, legacy missing/zero `sessionVersion`, nonzero version mismatch, rejection precedence, organization requirements, and super-admin organization compatibility. +- Added the Go Identity `Resolver` as one deep external seam from a signed legacy Cookie to a database-refreshed `Session`. Authentication denials collapse through `ErrUnauthenticated` while retaining stable internal rejection reasons; persistence failures remain distinct and propagate to the caller. +- Added a Go PostgreSQL authorization-snapshot Adapter that implements the Resolver's single persistence Interface with one explicit-column, parameterized `LEFT JOIN` over `public.platform_users` and `public.platform_organizations`. It deliberately returns disabled/missing organization state to the Identity Module instead of hiding policy in SQL. +- Added the equivalent TypeScript authorization seam and PostgreSQL snapshot store, then changed `getOptionalAuthSession` to use them. PostgreSQL request revalidation now uses one joined query rather than separate account and organization reads; the local development path projects the same narrow snapshot without exposing password storage fields. +- Closed the existing authorization gap that accepted non-super-admin accounts with no organization. All non-super-admin accounts now require a matching, active organization; an unbound super-admin remains valid. +- Kept login, password changes, Middleware, Go identity HTTP routes, Docker/ACK manifests, Ingress paths, and production traffic ownership unchanged. The Go Resolver is implemented and tested but remains intentionally unrouted. + +## Verification + +- Vertical TDD RED evidence was captured independently before the Go Resolver, Go PostgreSQL Adapter, TypeScript authorization seam, and TypeScript snapshot store implementations existed. Intermediate RED assertions also exposed missing organization-admin claim reconstruction and local snapshot leakage before those paths reached GREEN. +- `npm test -- --reporter=dot`: 36 test files and 135 tests passed, including both consumers of the shared authorization matrix and the PostgreSQL/local snapshot-store contract. +- `npx tsc --noEmit --incremental false`: passed. +- `npm run build`: Next.js 15.5.18 production build completed for all current routes; the pre-existing multiple-lockfile workspace-root warning remains. +- `npm run go:test`: all five Go packages passed. +- `npm run go:vet`: passed. +- `npm run go:build`: built `cmd/zhinian-api` successfully through the repository's cross-platform runner. +- `npm run deploy:check`: all 8 ACK manifest assertions passed, confirming that production routing and workload ownership were not changed. +- `git diff --check` and `gofmt -d` over all changed Go files: passed with no output. +- The independent Sol review found one cross-language composite-failure ordering mismatch. A fixture case was strengthened to combine an unknown database role with a nonzero version mismatch, producing the expected TypeScript RED (`invalid_role` versus `session_version_mismatch`); TypeScript was then aligned with Go so version mismatch has stable precedence. A second review caught that this temporarily removed standalone `invalid_role` coverage, so a redundant standalone version-mismatch case was converted to preserve both reasons while retaining all organization cases and the 14-case total. Both focused contracts and all verification above passed again. + +## Follow-ups + +- Add the first Go current-user/authentication HTTP Adapter only after its response, 401/403/500 mapping, Cookie transport, and black-box parity contracts are frozen; this task does not claim `/api/auth/me` or another identity path. +- Move or duplicate database-refreshed authorization at the routing boundary before any protected route cutover. The current Next.js Middleware still performs signed-Cookie-only gating, while server Route Handlers perform the authoritative database refresh. +- Add a Go local-development authorization snapshot Adapter before expecting the unrouted Go process to serve identity flows with `ZHINIAN_DATA_BACKEND=local`. +- Exercise the joined identity query against the production-like RDS role, verified-CA TLS, schema privileges, disabled accounts, deleted organizations, and concurrent account mutations before cutover. +- Migrate login/logout/password and lockout mutation flows as separate vertical slices so session-version invalidation and single-writer ownership can be reviewed independently. + +## Promotion Candidates + +- Target: `.project-docs/30-worklog/current-state.md`, `.project-docs/20-architecture/module-map.md`, and `.project-docs/20-architecture/data-flow.md`. + Proposal: after integration, record the implemented but unrouted Go Identity Resolver, its single PostgreSQL authorization-snapshot seam, and the shared TypeScript/Go authorization contract. + Evidence: the 14-case shared fixture, both language implementations, exact joined-query tests, and complete Node/Go/build/ACK verification in this task. + Future impact: later HTTP slices can consume one current-session result instead of reimplementing Cookie parsing, account/organization lookup order, or role/session-version rules. + Semantic conflicts: canonical documents still describe the Go backend as a foundation with no identity authorization slice; they should change only when this feature is integrated. ACK-001 and Next.js route ownership remain unchanged. + Human confirmation required: no new architecture direction is required; promotion still belongs to the serialized Integration Gate. + +- Target: `.project-docs/40-domain/business-rules.md`. + Proposal: record that every active non-super-admin platform account must reference the same active organization returned by the authorization snapshot, while a super-admin may be unbound; missing/zero legacy session versions are temporarily accepted and refreshed, while nonzero mismatches revoke the session. + Evidence: the shared authorization matrix and both TypeScript/Go Resolver contract suites. + Future impact: account deletion, organization archival, role changes, session invalidation, and future authorization Adapters must preserve this rule. + Semantic conflicts: this deliberately closes a prior TypeScript conditional gap and aligns runtime authorization with the existing account mutation/domain constraints. + Human confirmation required: no; the task scope explicitly selected this security rule and no production route ownership changed. diff --git a/backend/README.md b/backend/README.md index b0f6d02..4a6b126 100644 --- a/backend/README.md +++ b/backend/README.md @@ -7,7 +7,8 @@ unchanged until later route-by-route cutover work passes the shared contracts. Implemented Modules: -- `identity`: legacy `zhinian_session` HMAC, parsing, and chunking contract. +- `identity`: legacy `zhinian_session` HMAC/chunking plus database-refreshed + account, organization, role, and `sessionVersion` authorization. - `postgres`: fail-closed configuration, verified-CA TLS, readiness, and calls to the existing atomic claim and wallet PostgreSQL functions. - `httpapi`: process health and database readiness handlers. @@ -32,3 +33,7 @@ ZHINIAN_DATA_BACKEND=local GO_BACKEND_PORT=8080 ./backend/zhinian-api Only `/api/health` and `/api/ready` are implemented in this foundation. No Ingress, Docker, ACK, Secret, or Worker ownership has moved to Go yet. + +The Identity resolver and PostgreSQL authorization-snapshot Adapter are +implemented and tested, but no Go login or current-user HTTP route is exposed +yet. Route ownership remains with Next.js until a later path-level cutover. diff --git a/backend/internal/identity/resolver.go b/backend/internal/identity/resolver.go new file mode 100644 index 0000000..5626fd8 --- /dev/null +++ b/backend/internal/identity/resolver.go @@ -0,0 +1,177 @@ +package identity + +import ( + "context" + "errors" + "fmt" + "time" +) + +// AuthorizationSnapshotLoader is the Identity module's single persistence +// seam. A false found result means that the account does not exist. +type AuthorizationSnapshotLoader interface { + FindAuthorizationSnapshot(context.Context, string) (AuthorizationSnapshot, bool, error) +} + +// AuthorizationSnapshot contains all database-authoritative claims needed to +// authorize one signed session. +type AuthorizationSnapshot struct { + Account AccountSnapshot `json:"account"` + Organization *OrganizationSnapshot `json:"organization"` +} + +type AccountSnapshot struct { + ID string `json:"id"` + Phone string `json:"phone"` + DisplayName string `json:"displayName"` + Role string `json:"role"` + OrganizationID string `json:"organizationId,omitempty"` + Status string `json:"status"` + SessionVersion int `json:"sessionVersion"` +} + +type OrganizationSnapshot struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` +} + +type RejectionReason string + +const ( + RejectionInvalidSession RejectionReason = "invalid_session" + RejectionClientMismatch RejectionReason = "client_mismatch" + RejectionAccountNotFound RejectionReason = "account_not_found" + RejectionAccountDisabled RejectionReason = "account_disabled" + RejectionSessionVersionMismatch RejectionReason = "session_version_mismatch" + RejectionOrganizationRequired RejectionReason = "organization_required" + RejectionOrganizationNotActive RejectionReason = "organization_not_active" + RejectionInvalidRole RejectionReason = "invalid_role" +) + +var ErrUnauthenticated = errors.New("unauthenticated") + +// UnauthenticatedError retains a diagnostic reason while allowing callers to +// collapse all authorization denials with errors.Is(err, ErrUnauthenticated). +type UnauthenticatedError struct { + Reason RejectionReason +} + +func (err *UnauthenticatedError) Error() string { + return fmt.Sprintf("%s: %s", ErrUnauthenticated, err.Reason) +} + +func (err *UnauthenticatedError) Unwrap() error { + return ErrUnauthenticated +} + +type Resolver struct { + loader AuthorizationSnapshotLoader + secret string + requiredClientID string + now func() time.Time +} + +func NewResolver(loader AuthorizationSnapshotLoader, secret, requiredClientID string, now func() time.Time) *Resolver { + if now == nil { + now = time.Now + } + if requiredClientID == "" { + requiredClientID = "platform" + } + return &Resolver{ + loader: loader, + secret: secret, + requiredClientID: requiredClientID, + now: now, + } +} + +// Resolve authenticates the signed cookie, reloads its account authorization +// state, and returns a session whose authorization claims all come from the +// database snapshot. +func (resolver *Resolver) Resolve(ctx context.Context, cookieValue string) (Session, error) { + if resolver == nil || resolver.loader == nil || resolver.secret == "" { + return Session{}, fmt.Errorf("identity resolver is not configured") + } + session, err := Parse(cookieValue, resolver.secret, resolver.now()) + if err != nil { + return Session{}, reject(RejectionInvalidSession) + } + if session.User.ClientID != resolver.requiredClientID { + return Session{}, reject(RejectionClientMismatch) + } + + snapshot, found, err := resolver.loader.FindAuthorizationSnapshot(ctx, session.User.ID) + if err != nil { + return Session{}, err + } + if !found { + return Session{}, reject(RejectionAccountNotFound) + } + account := snapshot.Account + if account.Status != "active" { + return Session{}, reject(RejectionAccountDisabled) + } + if session.SessionVersion != nil && *session.SessionVersion != 0 && *session.SessionVersion != account.SessionVersion { + return Session{}, reject(RejectionSessionVersionMismatch) + } + + authMode, authorities, validRole := roleClaims(account.Role) + if !validRole { + return Session{}, reject(RejectionInvalidRole) + } + if account.Role != "super_admin" { + if account.OrganizationID == "" { + return Session{}, reject(RejectionOrganizationRequired) + } + if snapshot.Organization == nil || snapshot.Organization.ID != account.OrganizationID || snapshot.Organization.Status != "active" { + return Session{}, reject(RejectionOrganizationNotActive) + } + } + + currentVersion := account.SessionVersion + resolved := Session{ + Version: session.Version, + AuthMode: authMode, + IssuedAt: session.IssuedAt, + ExpiresAt: session.ExpiresAt, + SessionVersion: ¤tVersion, + AccessToken: session.AccessToken, + TokenType: session.TokenType, + User: User{ + ID: account.ID, + Subject: account.ID, + Username: account.Phone, + Phone: account.Phone, + DisplayName: account.DisplayName, + ClientID: resolver.requiredClientID, + OrganizationID: account.OrganizationID, + Role: account.Role, + Status: account.Status, + Authorities: authorities, + Scope: []string{}, + }, + } + if snapshot.Organization != nil && snapshot.Organization.ID == account.OrganizationID { + resolved.User.OrganizationName = snapshot.Organization.Name + } + return resolved, nil +} + +func roleClaims(role string) (AuthMode, []string, bool) { + switch role { + case "user": + return AuthModeUser, []string{"ROLE_USER"}, true + case "organization_admin": + return AuthModeAdmin, []string{"ROLE_ORGANIZATION_ADMIN", "ORGANIZATION_ADMIN"}, true + case "super_admin": + return AuthModeAdmin, []string{"ROLE_SUPER_ADMIN", "SUPER_ADMIN"}, true + default: + return "", nil, false + } +} + +func reject(reason RejectionReason) error { + return &UnauthenticatedError{Reason: reason} +} diff --git a/backend/internal/identity/resolver_test.go b/backend/internal/identity/resolver_test.go new file mode 100644 index 0000000..a5da471 --- /dev/null +++ b/backend/internal/identity/resolver_test.go @@ -0,0 +1,195 @@ +package identity + +import ( + "context" + "encoding/json" + "errors" + "os" + "reflect" + "testing" + "time" +) + +type authorizationFixture struct { + RequiredClientID string `json:"requiredClientId"` + SessionSecret string `json:"sessionSecret"` + NowUnix int64 `json:"nowUnix"` + Cases []struct { + Name string `json:"name"` + Session Session `json:"session"` + Snapshot *AuthorizationSnapshot `json:"snapshot"` + Expected struct { + Outcome string `json:"outcome"` + Reason RejectionReason `json:"reason"` + LoaderCalls int `json:"loaderCalls"` + Session Session `json:"session"` + } `json:"expected"` + } `json:"cases"` +} + +type recordingAuthorizationLoader struct { + snapshot AuthorizationSnapshot + found bool + err error + ids []string +} + +func (loader *recordingAuthorizationLoader) FindAuthorizationSnapshot(_ context.Context, id string) (AuthorizationSnapshot, bool, error) { + loader.ids = append(loader.ids, id) + return loader.snapshot, loader.found, loader.err +} + +func TestResolverDrivesPlatformAuthorizationContract(t *testing.T) { + fixture := loadAuthorizationFixture(t) + if len(fixture.Cases) != 14 { + t.Fatalf("authorization fixture cases = %d, want 14", len(fixture.Cases)) + } + + for _, testCase := range fixture.Cases { + t.Run(testCase.Name, func(t *testing.T) { + cookie, err := json.Marshal(testCase.Session) + if err != nil { + t.Fatalf("marshal session fixture: %v", err) + } + signed, err := Sign(cookie, fixture.SessionSecret) + if err != nil { + t.Fatalf("sign session fixture: %v", err) + } + + loader := &recordingAuthorizationLoader{} + if testCase.Snapshot != nil { + loader.snapshot = *testCase.Snapshot + loader.found = true + } + resolver := NewResolver(loader, fixture.SessionSecret, fixture.RequiredClientID, func() time.Time { + return time.Unix(fixture.NowUnix, 0) + }) + + got, resolveErr := resolver.Resolve(context.Background(), signed) + if len(loader.ids) != testCase.Expected.LoaderCalls { + t.Fatalf("loader calls = %d, want %d", len(loader.ids), testCase.Expected.LoaderCalls) + } + if len(loader.ids) == 1 && loader.ids[0] != testCase.Session.User.ID { + t.Fatalf("loader ID = %q, want %q", loader.ids[0], testCase.Session.User.ID) + } + + switch testCase.Expected.Outcome { + case "authenticated": + if resolveErr != nil { + t.Fatalf("Resolve() error = %v", resolveErr) + } + if !reflect.DeepEqual(got, testCase.Expected.Session) { + t.Errorf("resolved session mismatch\n got: %#v\nwant: %#v", got, testCase.Expected.Session) + } + case "unauthenticated": + var rejection *UnauthenticatedError + if !errors.As(resolveErr, &rejection) { + t.Fatalf("Resolve() error = %v, want typed unauthenticated rejection", resolveErr) + } + if !errors.Is(resolveErr, ErrUnauthenticated) { + t.Errorf("Resolve() must collapse to ErrUnauthenticated") + } + if rejection.Reason != testCase.Expected.Reason { + t.Errorf("rejection reason = %q, want %q", rejection.Reason, testCase.Expected.Reason) + } + default: + t.Fatalf("unsupported fixture outcome %q", testCase.Expected.Outcome) + } + }) + } +} + +func TestResolverPropagatesLoaderError(t *testing.T) { + fixture := loadAuthorizationFixture(t) + databaseErr := errors.New("database unavailable") + loader := &recordingAuthorizationLoader{err: databaseErr} + resolver := NewResolver(loader, fixture.SessionSecret, fixture.RequiredClientID, func() time.Time { + return time.Unix(fixture.NowUnix, 0) + }) + signed := signFixtureSession(t, fixture.Cases[0].Session, fixture.SessionSecret) + + _, err := resolver.Resolve(context.Background(), signed) + if !errors.Is(err, databaseErr) { + t.Fatalf("Resolve() error = %v, want database error", err) + } + if errors.Is(err, ErrUnauthenticated) { + t.Fatal("database error must not collapse to unauthenticated") + } + if !reflect.DeepEqual(loader.ids, []string{fixture.Cases[0].Session.User.ID}) { + t.Fatalf("loader IDs = %#v", loader.ids) + } +} + +func TestResolverRejectsInvalidCookieWithoutLoadingAuthorization(t *testing.T) { + fixture := loadAuthorizationFixture(t) + loader := &recordingAuthorizationLoader{} + resolver := NewResolver(loader, fixture.SessionSecret, fixture.RequiredClientID, func() time.Time { + return time.Unix(fixture.NowUnix, 0) + }) + + _, err := resolver.Resolve(context.Background(), "not-a-signed-session") + var rejection *UnauthenticatedError + if !errors.As(err, &rejection) || rejection.Reason != RejectionInvalidSession { + t.Fatalf("Resolve() error = %v, want invalid-session rejection", err) + } + if len(loader.ids) != 0 { + t.Fatalf("loader calls = %d, want 0", len(loader.ids)) + } +} + +func TestNewResolverDefaultsRequiredPlatformClient(t *testing.T) { + fixture := loadAuthorizationFixture(t) + loader := &recordingAuthorizationLoader{} + resolver := NewResolver(loader, fixture.SessionSecret, "", func() time.Time { + return time.Unix(fixture.NowUnix, 0) + }) + signed := signFixtureSession(t, fixture.Cases[5].Session, fixture.SessionSecret) + + _, err := resolver.Resolve(context.Background(), signed) + var rejection *UnauthenticatedError + if !errors.As(err, &rejection) || rejection.Reason != RejectionClientMismatch { + t.Fatalf("Resolve() error = %v, want client mismatch with default platform client", err) + } + if len(loader.ids) != 0 { + t.Fatalf("loader calls = %d, want 0", len(loader.ids)) + } +} + +func TestResolverFailsClosedWhenNotConfigured(t *testing.T) { + for _, resolver := range []*Resolver{ + nil, + NewResolver(nil, "secret", "platform", nil), + NewResolver(&recordingAuthorizationLoader{}, "", "platform", nil), + } { + _, err := resolver.Resolve(context.Background(), "cookie") + if err == nil || errors.Is(err, ErrUnauthenticated) { + t.Fatalf("Resolve() error = %v, want configuration failure", err) + } + } +} + +func loadAuthorizationFixture(t *testing.T) authorizationFixture { + t.Helper() + raw, err := os.ReadFile("../../../contracts/auth/platform-session-authorization-v1.json") + if err != nil { + t.Fatalf("read authorization fixture: %v", err) + } + var fixture authorizationFixture + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatalf("decode authorization fixture: %v", err) + } + return fixture +} + +func signFixtureSession(t *testing.T, session Session, secret string) string { + t.Helper() + raw, err := json.Marshal(session) + if err != nil { + t.Fatalf("marshal fixture session: %v", err) + } + signed, err := Sign(raw, secret) + if err != nil { + t.Fatalf("sign fixture session: %v", err) + } + return signed +} diff --git a/backend/internal/postgres/identity.go b/backend/internal/postgres/identity.go new file mode 100644 index 0000000..01d73e0 --- /dev/null +++ b/backend/internal/postgres/identity.go @@ -0,0 +1,78 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" +) + +const FindAuthorizationSnapshotSQL = `SELECT + u.id, + u.phone, + u.display_name, + u.role, + u.organization_id, + u.status, + u.session_version, + o.id, + o.name, + o.status +FROM public.platform_users AS u +LEFT JOIN public.platform_organizations AS o ON o.id = u.organization_id +WHERE u.id = $1::text` + +// FindAuthorizationSnapshot loads all database-authoritative identity claims in +// one query. Account and organization state are deliberately not filtered so +// the Identity module can apply one authorization policy to every result. +func (db *Database) FindAuthorizationSnapshot(ctx context.Context, identityKey string) (identity.AuthorizationSnapshot, bool, error) { + if db.config.Backend != BackendPostgres || db.querier == nil { + return identity.AuthorizationSnapshot{}, false, fmt.Errorf("PostgreSQL is unavailable when ZHINIAN_DATA_BACKEND=%s", db.config.Backend) + } + rows, err := db.querier.Query(ctx, FindAuthorizationSnapshotSQL, identityKey) + if err != nil { + return identity.AuthorizationSnapshot{}, false, fmt.Errorf("query authorization snapshot: %w", err) + } + defer rows.Close() + if !rows.Next() { + if err := rows.Err(); err != nil { + return identity.AuthorizationSnapshot{}, false, fmt.Errorf("read authorization snapshot: %w", err) + } + return identity.AuthorizationSnapshot{}, false, nil + } + + var snapshot identity.AuthorizationSnapshot + var organizationID sql.NullString + var joinedOrganizationID sql.NullString + var organizationName sql.NullString + var organizationStatus sql.NullString + if err := rows.Scan( + &snapshot.Account.ID, + &snapshot.Account.Phone, + &snapshot.Account.DisplayName, + &snapshot.Account.Role, + &organizationID, + &snapshot.Account.Status, + &snapshot.Account.SessionVersion, + &joinedOrganizationID, + &organizationName, + &organizationStatus, + ); err != nil { + return identity.AuthorizationSnapshot{}, false, fmt.Errorf("scan authorization snapshot: %w", err) + } + if organizationID.Valid { + snapshot.Account.OrganizationID = organizationID.String + } + if joinedOrganizationID.Valid { + snapshot.Organization = &identity.OrganizationSnapshot{ + ID: joinedOrganizationID.String, + Name: organizationName.String, + Status: organizationStatus.String, + } + } + if err := rows.Err(); err != nil { + return identity.AuthorizationSnapshot{}, false, fmt.Errorf("read authorization snapshot: %w", err) + } + return snapshot, true, nil +} diff --git a/backend/internal/postgres/identity_test.go b/backend/internal/postgres/identity_test.go new file mode 100644 index 0000000..dacb7cc --- /dev/null +++ b/backend/internal/postgres/identity_test.go @@ -0,0 +1,206 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "reflect" + "testing" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" +) + +func TestFindAuthorizationSnapshotLoadsAccountAndOrganizationInOneQuery(t *testing.T) { + const wantSQL = `SELECT + u.id, + u.phone, + u.display_name, + u.role, + u.organization_id, + u.status, + u.session_version, + o.id, + o.name, + o.status +FROM public.platform_users AS u +LEFT JOIN public.platform_organizations AS o ON o.id = u.organization_id +WHERE u.id = $1::text` + rows := &identityRows{rows: [][]any{{ + "account-1", "13800138000", "Zhang San", "organization_admin", "organization-1", "active", 7, + "organization-1", "Acme", "active", + }}} + querier := &identityQuerier{rows: rows} + db := NewDatabase(Config{Backend: BackendPostgres}, querier) + + got, found, err := db.FindAuthorizationSnapshot(context.Background(), "account-1") + if err != nil { + t.Fatalf("FindAuthorizationSnapshot() error = %v", err) + } + if !found { + t.Fatal("FindAuthorizationSnapshot() found = false, want true") + } + if querier.sql != wantSQL || !reflect.DeepEqual(querier.args, []any{"account-1"}) { + t.Fatalf("query = %q args = %#v, want exact SQL and identity argument", querier.sql, querier.args) + } + want := identity.AuthorizationSnapshot{ + Account: identity.AccountSnapshot{ + ID: "account-1", Phone: "13800138000", DisplayName: "Zhang San", Role: "organization_admin", + OrganizationID: "organization-1", Status: "active", SessionVersion: 7, + }, + Organization: &identity.OrganizationSnapshot{ID: "organization-1", Name: "Acme", Status: "active"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("FindAuthorizationSnapshot() = %#v, want %#v", got, want) + } +} + +func TestFindAuthorizationSnapshotLoadsUnboundSuperAdminWithNoOrganization(t *testing.T) { + querier := &identityQuerier{rows: &identityRows{rows: [][]any{{ + "super-1", "13900139000", "Root", "super_admin", nil, "active", 4, + nil, nil, nil, + }}}} + db := NewDatabase(Config{Backend: BackendPostgres}, querier) + + got, found, err := db.FindAuthorizationSnapshot(context.Background(), "super-1") + if err != nil { + t.Fatalf("FindAuthorizationSnapshot() error = %v", err) + } + if !found || got.Account.OrganizationID != "" || got.Organization != nil { + t.Fatalf("FindAuthorizationSnapshot() = (%#v, %v), want unbound account and nil organization", got, found) + } +} + +func TestFindAuthorizationSnapshotReturnsNotFoundForNoAccount(t *testing.T) { + db := NewDatabase(Config{Backend: BackendPostgres}, &identityQuerier{rows: &identityRows{}}) + + got, found, err := db.FindAuthorizationSnapshot(context.Background(), "missing") + if err != nil || found || !reflect.DeepEqual(got, identity.AuthorizationSnapshot{}) { + t.Fatalf("FindAuthorizationSnapshot() = (%#v, %v, %v), want zero, false, nil", got, found, err) + } +} + +func TestFindAuthorizationSnapshotPropagatesDatabaseFailures(t *testing.T) { + queryErr := errors.New("query failed") + scanErr := errors.New("scan failed") + rowsErr := errors.New("rows failed") + tests := []struct { + name string + querier *identityQuerier + wantErr error + }{ + {name: "query", querier: &identityQuerier{err: queryErr}, wantErr: queryErr}, + {name: "scan", querier: &identityQuerier{rows: &identityRows{rows: [][]any{{nil}}, scanErr: scanErr}}, wantErr: scanErr}, + {name: "rows before first row", querier: &identityQuerier{rows: &identityRows{err: rowsErr}}, wantErr: rowsErr}, + {name: "rows after scan", querier: &identityQuerier{rows: &identityRows{ + rows: [][]any{{"account-1", "13800138000", "Name", "user", "org-1", "active", 1, "org-1", "Org", "active"}}, + err: rowsErr, + }}, wantErr: rowsErr}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + db := NewDatabase(Config{Backend: BackendPostgres}, test.querier) + got, found, err := db.FindAuthorizationSnapshot(context.Background(), "account-1") + if !errors.Is(err, test.wantErr) { + t.Fatalf("FindAuthorizationSnapshot() error = %v, want wrapping %v", err, test.wantErr) + } + if found || !reflect.DeepEqual(got, identity.AuthorizationSnapshot{}) { + t.Fatalf("FindAuthorizationSnapshot() = (%#v, %v), want fail-closed zero result", got, found) + } + }) + } +} + +func TestFindAuthorizationSnapshotFailsClosedWithoutPostgres(t *testing.T) { + tests := []struct { + name string + config Config + querier Querier + }{ + {name: "local backend", config: Config{Backend: BackendLocal}, querier: &identityQuerier{err: errors.New("must not query")}}, + {name: "unavailable pool", config: Config{Backend: BackendPostgres}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + db := NewDatabase(test.config, test.querier) + got, found, err := db.FindAuthorizationSnapshot(context.Background(), "account-1") + if err == nil || found || !reflect.DeepEqual(got, identity.AuthorizationSnapshot{}) { + t.Fatalf("FindAuthorizationSnapshot() = (%#v, %v, %v), want fail-closed error", got, found, err) + } + if querier, ok := test.querier.(*identityQuerier); ok && querier.called { + t.Fatal("FindAuthorizationSnapshot() queried while PostgreSQL was unavailable") + } + }) + } +} + +var _ identity.AuthorizationSnapshotLoader = (*Database)(nil) + +type identityQuerier struct { + rows *identityRows + err error + sql string + args []any + called bool +} + +func (q *identityQuerier) Query(_ context.Context, query string, args ...any) (Rows, error) { + q.called = true + q.sql = query + q.args = args + return q.rows, q.err +} + +type identityRows struct { + rows [][]any + idx int + err error + scanErr error +} + +func (r *identityRows) Close() {} +func (r *identityRows) Err() error { return r.err } +func (r *identityRows) Next() bool { return r.idx < len(r.rows) } + +func (r *identityRows) Scan(dest ...any) error { + if r.scanErr != nil { + return r.scanErr + } + if r.idx >= len(r.rows) { + return errors.New("scan past end") + } + row := r.rows[r.idx] + r.idx++ + if len(dest) != len(row) { + return errors.New("scan arity mismatch") + } + for index, target := range dest { + value := row[index] + switch target := target.(type) { + case *string: + text, ok := value.(string) + if !ok { + return errors.New("scan string type mismatch") + } + *target = text + case *int: + number, ok := value.(int) + if !ok { + return errors.New("scan int type mismatch") + } + *target = number + case *sql.NullString: + if value == nil { + *target = sql.NullString{} + continue + } + text, ok := value.(string) + if !ok { + return errors.New("scan nullable string type mismatch") + } + *target = sql.NullString{String: text, Valid: true} + default: + return errors.New("unsupported scan target") + } + } + return nil +} diff --git a/contracts/auth/platform-session-authorization-v1.json b/contracts/auth/platform-session-authorization-v1.json new file mode 100644 index 0000000..7f297ac --- /dev/null +++ b/contracts/auth/platform-session-authorization-v1.json @@ -0,0 +1,637 @@ +{ + "version": 1, + "requiredClientId": "platform", + "sessionSecret": "test-platform-authorization-secret-with-enough-entropy", + "nowUnix": 150, + "cases": [ + { + "name": "active user refreshes forged Cookie claims from the database", + "session": { + "version": 1, + "authMode": "admin", + "issuedAt": 100, + "expiresAt": 200, + "sessionVersion": 7, + "accessToken": "access-token-1", + "tokenType": "bearer", + "user": { + "id": "user-1", + "subject": "forged-subject", + "username": "forged-admin", + "phone": "000000", + "displayName": "Forged Admin", + "clientId": "platform", + "organizationId": "org-forged", + "organizationName": "Forged Organization", + "role": "super_admin", + "status": "active", + "authorities": ["ROLE_SUPER_ADMIN"], + "scope": ["forged"] + } + }, + "snapshot": { + "account": { + "id": "user-1", + "phone": "13800138001", + "displayName": "普通用户", + "role": "user", + "organizationId": "org-1", + "status": "active", + "sessionVersion": 7 + }, + "organization": { + "id": "org-1", + "name": "第一组织", + "status": "active" + } + }, + "expected": { + "outcome": "authenticated", + "loaderCalls": 1, + "session": { + "version": 1, + "authMode": "user", + "issuedAt": 100, + "expiresAt": 200, + "sessionVersion": 7, + "accessToken": "access-token-1", + "tokenType": "bearer", + "user": { + "id": "user-1", + "subject": "user-1", + "username": "13800138001", + "phone": "13800138001", + "displayName": "普通用户", + "clientId": "platform", + "organizationId": "org-1", + "organizationName": "第一组织", + "role": "user", + "status": "active", + "authorities": ["ROLE_USER"], + "scope": [] + } + } + } + }, + { + "name": "organization administrator receives database-derived admin mode", + "session": { + "version": 1, + "authMode": "user", + "issuedAt": 100, + "expiresAt": 200, + "sessionVersion": 3, + "user": { + "id": "org-admin-1", + "subject": "org-admin-1", + "displayName": "Old Name", + "clientId": "platform", + "authorities": ["ROLE_USER"], + "scope": [] + } + }, + "snapshot": { + "account": { + "id": "org-admin-1", + "phone": "13800138002", + "displayName": "组织管理员", + "role": "organization_admin", + "organizationId": "org-1", + "status": "active", + "sessionVersion": 3 + }, + "organization": { + "id": "org-1", + "name": "第一组织", + "status": "active" + } + }, + "expected": { + "outcome": "authenticated", + "loaderCalls": 1, + "session": { + "version": 1, + "authMode": "admin", + "issuedAt": 100, + "expiresAt": 200, + "sessionVersion": 3, + "user": { + "id": "org-admin-1", + "subject": "org-admin-1", + "username": "13800138002", + "phone": "13800138002", + "displayName": "组织管理员", + "clientId": "platform", + "organizationId": "org-1", + "organizationName": "第一组织", + "role": "organization_admin", + "status": "active", + "authorities": ["ROLE_ORGANIZATION_ADMIN", "ORGANIZATION_ADMIN"], + "scope": [] + } + } + } + }, + { + "name": "unbound super administrator remains valid", + "session": { + "version": 1, + "authMode": "admin", + "issuedAt": 100, + "expiresAt": 200, + "sessionVersion": 4, + "user": { + "id": "super-1", + "subject": "super-1", + "displayName": "Old Super", + "clientId": "platform", + "authorities": [], + "scope": [] + } + }, + "snapshot": { + "account": { + "id": "super-1", + "phone": "13800138003", + "displayName": "平台超级管理员", + "role": "super_admin", + "status": "active", + "sessionVersion": 4 + }, + "organization": null + }, + "expected": { + "outcome": "authenticated", + "loaderCalls": 1, + "session": { + "version": 1, + "authMode": "admin", + "issuedAt": 100, + "expiresAt": 200, + "sessionVersion": 4, + "user": { + "id": "super-1", + "subject": "super-1", + "username": "13800138003", + "phone": "13800138003", + "displayName": "平台超级管理员", + "clientId": "platform", + "role": "super_admin", + "status": "active", + "authorities": ["ROLE_SUPER_ADMIN", "SUPER_ADMIN"], + "scope": [] + } + } + } + }, + { + "name": "legacy missing session version is accepted and refreshed", + "session": { + "version": 1, + "authMode": "user", + "issuedAt": 100, + "expiresAt": 200, + "user": { + "id": "user-1", + "subject": "user-1", + "displayName": "Old Name", + "clientId": "platform", + "authorities": [], + "scope": [] + } + }, + "snapshot": { + "account": { + "id": "user-1", + "phone": "13800138001", + "displayName": "普通用户", + "role": "user", + "organizationId": "org-1", + "status": "active", + "sessionVersion": 7 + }, + "organization": { + "id": "org-1", + "name": "第一组织", + "status": "active" + } + }, + "expected": { + "outcome": "authenticated", + "loaderCalls": 1, + "session": { + "version": 1, + "authMode": "user", + "issuedAt": 100, + "expiresAt": 200, + "sessionVersion": 7, + "user": { + "id": "user-1", + "subject": "user-1", + "username": "13800138001", + "phone": "13800138001", + "displayName": "普通用户", + "clientId": "platform", + "organizationId": "org-1", + "organizationName": "第一组织", + "role": "user", + "status": "active", + "authorities": ["ROLE_USER"], + "scope": [] + } + } + } + }, + { + "name": "legacy zero session version is accepted and refreshed", + "session": { + "version": 1, + "authMode": "user", + "issuedAt": 100, + "expiresAt": 200, + "sessionVersion": 0, + "user": { + "id": "user-1", + "subject": "user-1", + "displayName": "Old Name", + "clientId": "platform", + "authorities": [], + "scope": [] + } + }, + "snapshot": { + "account": { + "id": "user-1", + "phone": "13800138001", + "displayName": "普通用户", + "role": "user", + "organizationId": "org-1", + "status": "active", + "sessionVersion": 7 + }, + "organization": { + "id": "org-1", + "name": "第一组织", + "status": "active" + } + }, + "expected": { + "outcome": "authenticated", + "loaderCalls": 1, + "session": { + "version": 1, + "authMode": "user", + "issuedAt": 100, + "expiresAt": 200, + "sessionVersion": 7, + "user": { + "id": "user-1", + "subject": "user-1", + "username": "13800138001", + "phone": "13800138001", + "displayName": "普通用户", + "clientId": "platform", + "organizationId": "org-1", + "organizationName": "第一组织", + "role": "user", + "status": "active", + "authorities": ["ROLE_USER"], + "scope": [] + } + } + } + }, + { + "name": "foreign client is rejected before database lookup", + "session": { + "version": 1, + "authMode": "admin", + "issuedAt": 100, + "expiresAt": 200, + "sessionVersion": 7, + "user": { + "id": "user-1", + "subject": "user-1", + "displayName": "Foreign User", + "clientId": "customPC", + "authorities": ["ROLE_SUPER_ADMIN"], + "scope": [] + } + }, + "snapshot": null, + "expected": { + "outcome": "unauthenticated", + "reason": "client_mismatch", + "loaderCalls": 0 + } + }, + { + "name": "missing account is rejected", + "session": { + "version": 1, + "authMode": "user", + "issuedAt": 100, + "expiresAt": 200, + "sessionVersion": 1, + "user": { + "id": "missing-user", + "subject": "missing-user", + "displayName": "Missing", + "clientId": "platform", + "authorities": [], + "scope": [] + } + }, + "snapshot": null, + "expected": { + "outcome": "unauthenticated", + "reason": "account_not_found", + "loaderCalls": 1 + } + }, + { + "name": "disabled account is rejected", + "session": { + "version": 1, + "authMode": "user", + "issuedAt": 100, + "expiresAt": 200, + "sessionVersion": 7, + "user": { + "id": "user-disabled", + "subject": "user-disabled", + "displayName": "Disabled", + "clientId": "platform", + "authorities": [], + "scope": [] + } + }, + "snapshot": { + "account": { + "id": "user-disabled", + "phone": "13800138004", + "displayName": "停用用户", + "role": "user", + "organizationId": "org-1", + "status": "disabled", + "sessionVersion": 7 + }, + "organization": { + "id": "org-1", + "name": "第一组织", + "status": "active" + } + }, + "expected": { + "outcome": "unauthenticated", + "reason": "account_disabled", + "loaderCalls": 1 + } + }, + { + "name": "standalone unknown database role is rejected", + "session": { + "version": 1, + "authMode": "user", + "issuedAt": 100, + "expiresAt": 200, + "sessionVersion": 7, + "user": { + "id": "user-1", + "subject": "user-1", + "displayName": "Old Name", + "clientId": "platform", + "authorities": [], + "scope": [] + } + }, + "snapshot": { + "account": { + "id": "user-1", + "phone": "13800138001", + "displayName": "普通用户", + "role": "auditor", + "organizationId": "org-1", + "status": "active", + "sessionVersion": 7 + }, + "organization": { + "id": "org-1", + "name": "第一组织", + "status": "active" + } + }, + "expected": { + "outcome": "unauthenticated", + "reason": "invalid_role", + "loaderCalls": 1 + } + }, + { + "name": "non-super account without organization is rejected", + "session": { + "version": 1, + "authMode": "user", + "issuedAt": 100, + "expiresAt": 200, + "sessionVersion": 2, + "user": { + "id": "unbound-user", + "subject": "unbound-user", + "displayName": "Unbound", + "clientId": "platform", + "authorities": [], + "scope": [] + } + }, + "snapshot": { + "account": { + "id": "unbound-user", + "phone": "13800138005", + "displayName": "无组织用户", + "role": "user", + "status": "active", + "sessionVersion": 2 + }, + "organization": null + }, + "expected": { + "outcome": "unauthenticated", + "reason": "organization_required", + "loaderCalls": 1 + } + }, + { + "name": "missing organization is rejected", + "session": { + "version": 1, + "authMode": "admin", + "issuedAt": 100, + "expiresAt": 200, + "sessionVersion": 2, + "user": { + "id": "orphan-admin", + "subject": "orphan-admin", + "displayName": "Orphan", + "clientId": "platform", + "authorities": [], + "scope": [] + } + }, + "snapshot": { + "account": { + "id": "orphan-admin", + "phone": "13800138006", + "displayName": "孤立管理员", + "role": "organization_admin", + "organizationId": "org-missing", + "status": "active", + "sessionVersion": 2 + }, + "organization": null + }, + "expected": { + "outcome": "unauthenticated", + "reason": "organization_not_active", + "loaderCalls": 1 + } + }, + { + "name": "disabled organization is rejected", + "session": { + "version": 1, + "authMode": "user", + "issuedAt": 100, + "expiresAt": 200, + "sessionVersion": 2, + "user": { + "id": "disabled-org-user", + "subject": "disabled-org-user", + "displayName": "Old Name", + "clientId": "platform", + "authorities": [], + "scope": [] + } + }, + "snapshot": { + "account": { + "id": "disabled-org-user", + "phone": "13800138007", + "displayName": "停用组织用户", + "role": "user", + "organizationId": "org-disabled", + "status": "active", + "sessionVersion": 2 + }, + "organization": { + "id": "org-disabled", + "name": "停用组织", + "status": "disabled" + } + }, + "expected": { + "outcome": "unauthenticated", + "reason": "organization_not_active", + "loaderCalls": 1 + } + }, + { + "name": "session version mismatch precedes unknown database role", + "session": { + "version": 1, + "authMode": "admin", + "issuedAt": 100, + "expiresAt": 200, + "sessionVersion": 1, + "user": { + "id": "invalid-role-user", + "subject": "invalid-role-user", + "displayName": "Invalid Role", + "clientId": "platform", + "authorities": ["ROLE_SUPER_ADMIN"], + "scope": [] + } + }, + "snapshot": { + "account": { + "id": "invalid-role-user", + "phone": "13800138008", + "displayName": "异常角色用户", + "role": "auditor", + "organizationId": "org-1", + "status": "active", + "sessionVersion": 2 + }, + "organization": { + "id": "org-1", + "name": "第一组织", + "status": "active" + } + }, + "expected": { + "outcome": "unauthenticated", + "reason": "session_version_mismatch", + "loaderCalls": 1 + } + }, + { + "name": "super administrator ignores disabled organization status", + "session": { + "version": 1, + "authMode": "user", + "issuedAt": 100, + "expiresAt": 200, + "sessionVersion": 5, + "user": { + "id": "bound-super", + "subject": "bound-super", + "displayName": "Old Super", + "clientId": "platform", + "authorities": [], + "scope": [] + } + }, + "snapshot": { + "account": { + "id": "bound-super", + "phone": "13800138009", + "displayName": "绑定超级管理员", + "role": "super_admin", + "organizationId": "org-disabled", + "status": "active", + "sessionVersion": 5 + }, + "organization": { + "id": "org-disabled", + "name": "停用组织", + "status": "disabled" + } + }, + "expected": { + "outcome": "authenticated", + "loaderCalls": 1, + "session": { + "version": 1, + "authMode": "admin", + "issuedAt": 100, + "expiresAt": 200, + "sessionVersion": 5, + "user": { + "id": "bound-super", + "subject": "bound-super", + "username": "13800138009", + "phone": "13800138009", + "displayName": "绑定超级管理员", + "clientId": "platform", + "organizationId": "org-disabled", + "organizationName": "停用组织", + "role": "super_admin", + "status": "active", + "authorities": ["ROLE_SUPER_ADMIN", "SUPER_ADMIN"], + "scope": [] + } + } + } + } + ] +} diff --git a/lib/server/auth/current-user.ts b/lib/server/auth/current-user.ts index ff8f2bb..861084b 100644 --- a/lib/server/auth/current-user.ts +++ b/lib/server/auth/current-user.ts @@ -3,8 +3,8 @@ import { SESSION_COOKIE_NAME, getAuthRuntimeConfig } from "@/lib/auth/config"; import { hasAdminSessionAccess, hasSuperAdminAccess } from "@/lib/auth/permissions"; import { parseSessionCookieValue, readChunkedCookieValue, type AuthSession, type AuthUser } from "@/lib/auth/session"; import { DEFAULT_OWNER_ID } from "@/lib/server/runtime"; -import { getPlatformOrganization, getPlatformUserById } from "@/lib/server/account-store"; -import { authUserFromPlatformRecord } from "@/lib/server/auth/local"; +import { loadPlatformAuthorizationSnapshot } from "@/lib/server/auth/platform-authorization-store"; +import { authorizePlatformSession } from "@/lib/server/auth/platform-session"; export class AuthRequiredError extends Error { status = 401; @@ -57,18 +57,9 @@ export async function getOptionalAuthSession(): Promise { readChunkedCookieValue(SESSION_COOKIE_NAME, (name) => cookieStore.get(name)?.value), config.sessionSecret ); - if (!session || session.user.clientId !== "platform") return null; - const account = await getPlatformUserById(session.user.id); - if (!account || account.status !== "active") return null; - if (session.sessionVersion && session.sessionVersion !== account.sessionVersion) return null; - const organization = account.organizationId ? await getPlatformOrganization(account.organizationId) : null; - if (account.role !== "super_admin" && account.organizationId && (!organization || organization.status !== "active")) return null; - return { - ...session, - authMode: account.role === "user" ? "user" : "admin", - user: authUserFromPlatformRecord(account, organization), - sessionVersion: account.sessionVersion - }; + if (!session) return null; + const authorization = await authorizePlatformSession(session, loadPlatformAuthorizationSnapshot); + return authorization.outcome === "authenticated" ? authorization.session : null; } export async function requireAppSession(): Promise { diff --git a/lib/server/auth/platform-authorization-store.ts b/lib/server/auth/platform-authorization-store.ts new file mode 100644 index 0000000..2f03995 --- /dev/null +++ b/lib/server/auth/platform-authorization-store.ts @@ -0,0 +1,69 @@ +import "server-only"; + +import { getPlatformOrganization, getPlatformUserById } from "@/lib/server/account-store"; +import type { PlatformAuthorizationSnapshot } from "@/lib/server/auth/platform-session"; +import { isPostgresBackend, queryDatabase } from "@/lib/server/database"; + +const AUTHORIZATION_SQL = `SELECT + users.id AS account_id, + users.phone AS account_phone, + users.display_name AS account_display_name, + users.role AS account_role, + users.organization_id AS account_organization_id, + users.status AS account_status, + users.session_version AS account_session_version, + organizations.id AS organization_id, + organizations.name AS organization_name, + organizations.status AS organization_status +FROM public.platform_users AS users +LEFT JOIN public.platform_organizations AS organizations + ON organizations.id = users.organization_id +WHERE users.id = $1`; + +export async function loadPlatformAuthorizationSnapshot( + accountId: string +): Promise { + if (!isPostgresBackend()) { + const account = await getPlatformUserById(accountId, { includeDisabled: true }); + if (!account) return null; + const organization = account.organizationId + ? await getPlatformOrganization(account.organizationId) + : null; + return { + account: { + id: account.id, + phone: account.phone, + displayName: account.displayName, + role: account.role, + organizationId: account.organizationId, + status: account.status, + sessionVersion: account.sessionVersion + }, + organization: organization + ? { id: organization.id, name: organization.name, status: organization.status } + : null + }; + } + + const { rows } = await queryDatabase>(AUTHORIZATION_SQL, [accountId]); + const row = rows[0]; + if (!row) return null; + return { + account: { + id: String(row.account_id), + phone: String(row.account_phone), + displayName: String(row.account_display_name), + role: String(row.account_role), + organizationId: row.account_organization_id == null ? undefined : String(row.account_organization_id), + status: String(row.account_status), + sessionVersion: Number(row.account_session_version) + }, + organization: row.organization_id == null + ? null + : { + id: String(row.organization_id), + name: String(row.organization_name), + status: String(row.organization_status) + } + }; +} diff --git a/lib/server/auth/platform-session.ts b/lib/server/auth/platform-session.ts new file mode 100644 index 0000000..f2064c8 --- /dev/null +++ b/lib/server/auth/platform-session.ts @@ -0,0 +1,105 @@ +import type { AuthSession, AuthUser } from "@/lib/auth/session"; +import type { PlatformRole } from "@/lib/types"; + +export type PlatformAuthorizationAccount = { + id: string; + phone: string; + displayName: string; + role: string; + organizationId?: string; + status: string; + sessionVersion: number; +}; + +export type PlatformAuthorizationOrganization = { + id: string; + name: string; + status: string; +}; + +export type PlatformAuthorizationSnapshot = { + account: PlatformAuthorizationAccount; + organization: PlatformAuthorizationOrganization | null; +}; + +export type PlatformSessionAuthorizationResult = + | { outcome: "authenticated"; session: AuthSession } + | { outcome: "unauthenticated"; reason: PlatformSessionRejectionReason }; + +export type PlatformSessionRejectionReason = + | "client_mismatch" + | "account_not_found" + | "account_disabled" + | "invalid_role" + | "session_version_mismatch" + | "organization_required" + | "organization_not_active"; + +export type PlatformAuthorizationLoader = ( + accountId: string, +) => PlatformAuthorizationSnapshot | null | Promise; + +export async function authorizePlatformSession( + session: AuthSession, + loadSnapshot: PlatformAuthorizationLoader, + requiredClientId = "platform", +): Promise { + if (session.user.clientId !== requiredClientId) { + return { outcome: "unauthenticated", reason: "client_mismatch" }; + } + + const snapshot = await loadSnapshot(session.user.id); + if (!snapshot) return { outcome: "unauthenticated", reason: "account_not_found" }; + + const { account, organization } = snapshot; + if (account.status !== "active") { + return { outcome: "unauthenticated", reason: "account_disabled" }; + } + if (session.sessionVersion && session.sessionVersion !== account.sessionVersion) { + return { outcome: "unauthenticated", reason: "session_version_mismatch" }; + } + if (!isPlatformRole(account.role)) { + return { outcome: "unauthenticated", reason: "invalid_role" }; + } + if (account.role !== "super_admin") { + if (!account.organizationId) { + return { outcome: "unauthenticated", reason: "organization_required" }; + } + if (!organization || organization.id !== account.organizationId || organization.status !== "active") { + return { outcome: "unauthenticated", reason: "organization_not_active" }; + } + } + + return { + outcome: "authenticated", + session: { + ...session, + authMode: account.role === "user" ? "user" : "admin", + sessionVersion: account.sessionVersion, + user: { + id: account.id, + subject: account.id, + username: account.phone, + phone: account.phone, + displayName: account.displayName, + clientId: requiredClientId, + organizationId: account.organizationId, + organizationName: organization?.name, + role: account.role, + status: "active", + authorities: authoritiesForRole(account.role), + scope: [], + }, + }, + }; +} + +function isPlatformRole(role: string): role is PlatformRole { + return role === "user" || role === "organization_admin" || role === "super_admin"; +} + +function authoritiesForRole(role: PlatformRole): AuthUser["authorities"] { + if (role === "super_admin") return ["ROLE_SUPER_ADMIN", "SUPER_ADMIN"]; + if (role === "organization_admin") return ["ROLE_ORGANIZATION_ADMIN", "ORGANIZATION_ADMIN"]; + return ["ROLE_USER"]; +} diff --git a/tests/auth-platform-authorization-store.test.ts b/tests/auth-platform-authorization-store.test.ts new file mode 100644 index 0000000..e452c8d --- /dev/null +++ b/tests/auth-platform-authorization-store.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { backend, getPlatformOrganization, getPlatformUserById, queryDatabase } = vi.hoisted(() => ({ + backend: { postgres: true }, + getPlatformOrganization: vi.fn(), + getPlatformUserById: vi.fn(), + queryDatabase: vi.fn() +})); + +vi.mock("@/lib/server/database", () => ({ + isPostgresBackend: () => backend.postgres, + queryDatabase +})); + +vi.mock("@/lib/server/account-store", () => ({ + getPlatformOrganization, + getPlatformUserById +})); + +import { loadPlatformAuthorizationSnapshot } from "@/lib/server/auth/platform-authorization-store"; + +const AUTHORIZATION_SQL = `SELECT + users.id AS account_id, + users.phone AS account_phone, + users.display_name AS account_display_name, + users.role AS account_role, + users.organization_id AS account_organization_id, + users.status AS account_status, + users.session_version AS account_session_version, + organizations.id AS organization_id, + organizations.name AS organization_name, + organizations.status AS organization_status +FROM public.platform_users AS users +LEFT JOIN public.platform_organizations AS organizations + ON organizations.id = users.organization_id +WHERE users.id = $1`; + +describe("platform authorization snapshot store", () => { + beforeEach(() => { + backend.postgres = true; + queryDatabase.mockReset(); + getPlatformUserById.mockReset(); + getPlatformOrganization.mockReset(); + }); + + it("loads a complete PostgreSQL authorization snapshot with one explicit parameterized join", async () => { + queryDatabase.mockResolvedValueOnce({ + rows: [{ + account_id: "account-1", + account_phone: "13800138000", + account_display_name: "Ada", + account_role: "organization_admin", + account_organization_id: "org-1", + account_status: "disabled", + account_session_version: 7, + organization_id: "org-1", + organization_name: "Research", + organization_status: "disabled" + }] + }); + + await expect(loadPlatformAuthorizationSnapshot("account-1")).resolves.toEqual({ + account: { + id: "account-1", + phone: "13800138000", + displayName: "Ada", + role: "organization_admin", + organizationId: "org-1", + status: "disabled", + sessionVersion: 7 + }, + organization: { id: "org-1", name: "Research", status: "disabled" } + }); + expect(queryDatabase).toHaveBeenCalledTimes(1); + expect(queryDatabase).toHaveBeenCalledWith(AUTHORIZATION_SQL, ["account-1"]); + expect(AUTHORIZATION_SQL).not.toMatch(/password|SELECT\s+\*/i); + expect(getPlatformUserById).not.toHaveBeenCalled(); + }); + + it("projects disabled local accounts without exposing password storage fields", async () => { + backend.postgres = false; + getPlatformUserById.mockResolvedValueOnce({ + id: "account-disabled", + phone: "13900139000", + displayName: "Grace", + role: "user", + organizationId: "org-disabled", + status: "disabled", + sessionVersion: 3, + passwordHash: "secret-hash", + passwordSalt: "secret-salt", + failedLoginCount: 4, + createdAt: "2026-08-13T00:00:00.000Z", + updatedAt: "2026-08-13T00:00:00.000Z" + }); + getPlatformOrganization.mockResolvedValueOnce({ + id: "org-disabled", + name: "Archived", + status: "disabled", + archiveOwnerId: "archive:org-disabled", + createdAt: "2026-08-13T00:00:00.000Z", + updatedAt: "2026-08-13T00:00:00.000Z" + }); + + await expect(loadPlatformAuthorizationSnapshot("account-disabled")).resolves.toEqual({ + account: { + id: "account-disabled", + phone: "13900139000", + displayName: "Grace", + role: "user", + organizationId: "org-disabled", + status: "disabled", + sessionVersion: 3 + }, + organization: { id: "org-disabled", name: "Archived", status: "disabled" } + }); + expect(getPlatformUserById).toHaveBeenCalledWith("account-disabled", { includeDisabled: true }); + expect(getPlatformOrganization).toHaveBeenCalledWith("org-disabled"); + expect(queryDatabase).not.toHaveBeenCalled(); + }); + + it("maps a PostgreSQL account with no joined organization", async () => { + queryDatabase.mockResolvedValueOnce({ + rows: [{ + account_id: "super-1", + account_phone: "13700137000", + account_display_name: "Lin", + account_role: "super_admin", + account_organization_id: null, + account_status: "active", + account_session_version: 11, + organization_id: null, + organization_name: null, + organization_status: null + }] + }); + + await expect(loadPlatformAuthorizationSnapshot("super-1")).resolves.toEqual({ + account: { + id: "super-1", + phone: "13700137000", + displayName: "Lin", + role: "super_admin", + organizationId: undefined, + status: "active", + sessionVersion: 11 + }, + organization: null + }); + }); + + it("returns null when PostgreSQL has no matching account", async () => { + queryDatabase.mockResolvedValueOnce({ rows: [] }); + + await expect(loadPlatformAuthorizationSnapshot("missing")).resolves.toBeNull(); + expect(queryDatabase).toHaveBeenCalledWith(AUTHORIZATION_SQL, ["missing"]); + }); + + it("propagates PostgreSQL query errors", async () => { + const failure = new Error("database unavailable"); + queryDatabase.mockRejectedValueOnce(failure); + + await expect(loadPlatformAuthorizationSnapshot("account-1")).rejects.toBe(failure); + }); + + it("returns null for a missing local account without loading an organization", async () => { + backend.postgres = false; + getPlatformUserById.mockResolvedValueOnce(null); + + await expect(loadPlatformAuthorizationSnapshot("missing")).resolves.toBeNull(); + expect(getPlatformUserById).toHaveBeenCalledWith("missing", { includeDisabled: true }); + expect(getPlatformOrganization).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/auth-platform-session-authorization-contract.test.ts b/tests/auth-platform-session-authorization-contract.test.ts new file mode 100644 index 0000000..683c602 --- /dev/null +++ b/tests/auth-platform-session-authorization-contract.test.ts @@ -0,0 +1,84 @@ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it, vi } from "vitest"; + +import type { AuthSession } from "@/lib/auth/session"; +import { + authorizePlatformSession, + type PlatformAuthorizationSnapshot, +} from "@/lib/server/auth/platform-session"; + +type ContractCase = { + name: string; + session: AuthSession; + snapshot: PlatformAuthorizationSnapshot | null; + expected: + | { outcome: "authenticated"; loaderCalls: number; session: AuthSession } + | { outcome: "unauthenticated"; loaderCalls: number; reason: string }; +}; + +type ContractFixture = { + version: 1; + requiredClientId: string; + cases: ContractCase[]; +}; + +const fixtureUrl = new URL("../contracts/auth/platform-session-authorization-v1.json", import.meta.url); + +async function loadFixture(): Promise { + return JSON.parse(await readFile(fixtureUrl, "utf8")) as ContractFixture; +} + +describe("platform session authorization v1 cross-language contract", () => { + it("refreshes an active user's forged Cookie claims from the database", async () => { + const fixture = await loadFixture(); + const contractCase = fixture.cases[0]; + const loader = vi.fn(async () => contractCase.snapshot); + + const result = await authorizePlatformSession( + contractCase.session, + loader, + fixture.requiredClientId, + ); + + expect(result).toEqual(contractCase.expected.outcome === "authenticated" + ? { outcome: "authenticated", session: contractCase.expected.session } + : { outcome: "unauthenticated", reason: contractCase.expected.reason }); + expect(loader).toHaveBeenCalledTimes(contractCase.expected.loaderCalls); + }); + + it("matches every remaining authorization and rejection case", async () => { + const fixture = await loadFixture(); + + for (const contractCase of fixture.cases.slice(1)) { + const loader = vi.fn(async () => contractCase.snapshot); + const result = await authorizePlatformSession( + contractCase.session, + loader, + fixture.requiredClientId, + ); + + expect(result, contractCase.name).toEqual(contractCase.expected.outcome === "authenticated" + ? { outcome: "authenticated", session: contractCase.expected.session } + : { outcome: "unauthenticated", reason: contractCase.expected.reason }); + expect(loader, contractCase.name).toHaveBeenCalledTimes(contractCase.expected.loaderCalls); + if (contractCase.expected.loaderCalls > 0) { + expect(loader, contractCase.name).toHaveBeenCalledWith(contractCase.session.user.id); + } + } + }); + + it("rejects a mismatched joined organization instead of trusting its display claims", async () => { + const fixture = await loadFixture(); + const contractCase = fixture.cases[0]; + const snapshot = structuredClone(contractCase.snapshot); + if (!snapshot?.organization) throw new Error("fixture requires organization"); + snapshot.organization.id = "org-other"; + + await expect(authorizePlatformSession( + contractCase.session, + async () => snapshot, + fixture.requiredClientId, + )).resolves.toEqual({ outcome: "unauthenticated", reason: "organization_not_active" }); + }); +});