diff --git a/.project-docs/30-worklog/tasks/20260813-go-remaining-modules-7d3a9e42.md b/.project-docs/30-worklog/tasks/20260813-go-remaining-modules-7d3a9e42.md new file mode 100644 index 0000000..1ef1d6f --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260813-go-remaining-modules-7d3a9e42.md @@ -0,0 +1,69 @@ +# Task: Complete remaining Go backend modules + +## Identity + +- Task ID: 20260813-go-remaining-modules-7d3a9e42 +- Mode: Feature +- Branch: codex/20260813-go-remaining-modules-7d3a9e42-go-remaining-modules +- Worktree: /Users/brother7/Documents/AI/NianAIGC-go-remaining-7d3a9e42 +- Base commit: d0207fcebe6ea4fb3ba80dce8c012b3c2170de40 +- Owner: codex +- Status: Planning + +## Scope + +- Complete every ADR-003 Go backend module that remains after the foundation, + database-refreshed identity, current-session HTTP, and password + login/logout slices already present at the task base. +- Freeze language-neutral compatibility contracts before each remaining + vertical slice and keep the TypeScript implementation as an executable + source-of-truth consumer until cutover. +- Implement the remaining Identity and Administration behavior; Assets and + storage/file serving; Billing and Usage; Jobs, providers, Webhooks, and the + embedded WorkerLoop; and the remaining public/compatibility HTTP surface. +- Compose all migrated routes into the separately runnable Go application and + prove route-surface coverage without moving production traffic. +- Keep production cutover, Next Route Handler deletion, Node Worker drain, + Docker/ACK/Ingress ownership changes, and real RDS/OSS rollout outside this + feature task. + +## Intent And Constraints + +- Follow vertical red-green TDD at stable external HTTP Interfaces and deep + domain/Adapter seams; do not create one shallow repository Interface per + table. +- Preserve current same-origin paths, method/status/JSON behavior, Cookie and + tenant authorization, owner-scoped not-found behavior, idempotency, job + state, wallet arithmetic, storage metadata, provider, and Webhook semantics. +- Keep PostgreSQL as the production source of relational truth and continue + using `claim_generation_jobs` and `billing_post_wallet_entry` for + cross-instance concurrency. Never replace them with process-local locks. +- Keep one owner for external side effects and every write path. The Go + implementation remains locally runnable and contract-tested but unrouted in + production until a later explicit cutover. +- Keep Alibaba Cloud OSS behind an object-storage Adapter and retain an + explicit local-development Adapter; do not claim horizontal production + safety until real OSS and RDS/TLS checks pass. +- Preserve the currently deployed ACK-001 Web/HTTP-polling-Worker topology and + all production Secrets/manifests during this implementation task. +- Work only in the owned worktree and task record; canonical project memory is + reserved for a serialized Integration Gate. + +## Outcome + +- Not completed. + +## Verification + +- Not run. + +## Follow-ups + +- Validate the complete backend against migrated non-production RDS with the + real application role and verified-CA TLS before production cutover. +- Validate OSS compatibility, provider credentials, external Webhooks, Worker + drain/recovery, and rollout/rollback against production-like infrastructure. + +## Promotion Candidates + +- None recorded. diff --git a/backend/internal/httpapi/platform_auth.go b/backend/internal/httpapi/platform_auth.go new file mode 100644 index 0000000..5a14bbb --- /dev/null +++ b/backend/internal/httpapi/platform_auth.go @@ -0,0 +1,145 @@ +package httpapi + +import ( + "errors" + "fmt" + "net/http" + "time" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" +) + +// PlatformRequirement is the route-level authorization policy evaluated after +// Identity has refreshed all account and organization claims from persistence. +type PlatformRequirement string + +const ( + PlatformApp PlatformRequirement = "app" + PlatformAdmin PlatformRequirement = "admin" + PlatformSuperAdmin PlatformRequirement = "super_admin" +) + +// PlatformAuthErrorKind is deliberately transport-oriented. Domain handlers +// can map it to the current stable status without learning Cookie/parser detail. +type PlatformAuthErrorKind string + +const ( + PlatformUnauthenticated PlatformAuthErrorKind = "unauthenticated" + PlatformForbidden PlatformAuthErrorKind = "forbidden" + PlatformConfigurationError PlatformAuthErrorKind = "configuration_error" +) + +// PlatformAuthError represents only expected authentication and RBAC denials. +// Resolver/database failures remain ordinary errors and must become generic 500s. +type PlatformAuthError struct { + Kind PlatformAuthErrorKind + Status int + Message string +} + +func (err *PlatformAuthError) Error() string { + if err.Message != "" { + return err.Message + } + return string(err.Kind) +} + +type PlatformAuthorizer struct { + state AuthState + resolver SessionResolver + now func() time.Time +} + +// NewPlatformAuthorizer creates the single HTTP-side platform authentication +// seam shared by protected route Modules. +func NewPlatformAuthorizer(state AuthState, resolver SessionResolver) (*PlatformAuthorizer, error) { + if state.Configured && resolver == nil { + return nil, fmt.Errorf("platform authorization: configured authentication requires a session resolver") + } + return &PlatformAuthorizer{state: state, resolver: resolver, now: time.Now}, nil +} + +// Authorize returns either a database-refreshed platform Session, the exact +// local-development fallback when authentication is optional, an expected +// typed denial, or an unclassified infrastructure error. +func (authorizer *PlatformAuthorizer) Authorize(r *http.Request, requirement PlatformRequirement) (identity.Session, error) { + if authorizer == nil { + return identity.Session{}, fmt.Errorf("platform authorizer is not configured") + } + if !validPlatformRequirement(requirement) { + return identity.Session{}, fmt.Errorf("unsupported platform authorization requirement %q", requirement) + } + + if authorizer.state.Configured { + cookieValue, found := readSessionCookie(r) + if found && len(cookieValue) <= identity.CookieMaxValueLength { + session, err := authorizer.resolver.Resolve(r.Context(), cookieValue) + if err == nil { + return authorizePlatformRole(session, requirement) + } + if !errors.Is(err, identity.ErrUnauthenticated) { + return identity.Session{}, err + } + } + } + + if !authorizer.state.Required { + return authorizePlatformRole(authorizer.localSession(), requirement) + } + if !authorizer.state.Configured { + return identity.Session{}, &PlatformAuthError{ + Kind: PlatformConfigurationError, Status: http.StatusServiceUnavailable, + Message: "认证配置不完整。", + } + } + return identity.Session{}, &PlatformAuthError{ + Kind: PlatformUnauthenticated, Status: http.StatusUnauthorized, + Message: "请先登录。", + } +} + +func authorizePlatformRole(session identity.Session, requirement PlatformRequirement) (identity.Session, error) { + allowed := requirement == PlatformApp + if requirement == PlatformAdmin { + allowed = session.AuthMode == identity.AuthModeAdmin && + (session.User.Role == "organization_admin" || session.User.Role == "super_admin") + } + if requirement == PlatformSuperAdmin { + allowed = session.User.Role == "super_admin" + } + if allowed { + return session, nil + } + return identity.Session{}, &PlatformAuthError{ + Kind: PlatformForbidden, Status: http.StatusForbidden, + Message: "需要管理员权限。", + } +} + +func validPlatformRequirement(requirement PlatformRequirement) bool { + return requirement == PlatformApp || requirement == PlatformAdmin || requirement == PlatformSuperAdmin +} + +func (authorizer *PlatformAuthorizer) localSession() identity.Session { + now := authorizer.now() + return identity.Session{ + Version: 1, + AuthMode: identity.AuthModeAdmin, + IssuedAt: now.Unix(), + ExpiresAt: now.Add(24 * time.Hour).Unix(), + User: identity.User{ + ID: "demo-merchant", + Subject: "demo-merchant", + Username: "13800000000", + Phone: "13800000000", + DisplayName: "智念用户", + ClientID: "local-dev", + OrganizationID: "org-demo", + OrganizationName: "演示组织", + Role: "super_admin", + Status: "active", + Authorities: []string{"zhinian_admin"}, + Scope: []string{}, + }, + } +} diff --git a/backend/internal/httpapi/platform_auth_test.go b/backend/internal/httpapi/platform_auth_test.go new file mode 100644 index 0000000..20cf1b1 --- /dev/null +++ b/backend/internal/httpapi/platform_auth_test.go @@ -0,0 +1,165 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" +) + +func TestPlatformAuthorizerMatchesSharedContract(t *testing.T) { + contract := loadPlatformHTTPAuthContract(t) + for _, contractCase := range contract.Cases { + t.Run(contractCase.Name, func(t *testing.T) { + resolver := &platformSessionResolverStub{outcome: contractCase.ResolverOutcome} + if contractCase.Session != nil { + resolver.session = fixturePlatformSession(*contractCase.Session) + } + authorizer, err := NewPlatformAuthorizer(AuthState(contractCase.State), resolver) + if err != nil { + t.Fatalf("NewPlatformAuthorizer: %v", err) + } + request := httptest.NewRequest(http.MethodGet, "/protected", nil) + if contractCase.ResolverOutcome != "not_called" { + request.AddCookie(&http.Cookie{Name: contract.CookieName, Value: "signed-cookie"}) + } + + session, err := authorizer.Authorize(request, PlatformRequirement(contractCase.Requirement)) + assertPlatformAuthOutcome(t, contractCase, session, err) + wantCalls := 0 + if contractCase.ResolverOutcome != "not_called" { + wantCalls = 1 + } + if resolver.calls != wantCalls { + t.Fatalf("resolver calls = %d, want %d", resolver.calls, wantCalls) + } + }) + } +} + +func TestPlatformAuthorizerUsesSharedChunkReader(t *testing.T) { + resolver := &platformSessionResolverStub{outcome: "authenticated", session: fixturePlatformSession(platformHTTPFixtureSession{AuthMode: "user", Role: "user"})} + authorizer, err := NewPlatformAuthorizer(AuthState{Required: true, Configured: true}, resolver) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "/protected", nil) + request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "first"}) + request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "last"}) + request.AddCookie(&http.Cookie{Name: identity.SessionCookieName + ".1", Value: "tail"}) + + if _, err := authorizer.Authorize(request, PlatformApp); err != nil { + t.Fatalf("Authorize: %v", err) + } + if resolver.value != "lasttail" { + t.Fatalf("resolver value = %q, want lasttail", resolver.value) + } +} + +type platformSessionResolverStub struct { + outcome string + session identity.Session + calls int + value string +} + +func (stub *platformSessionResolverStub) Resolve(_ context.Context, value string) (identity.Session, error) { + stub.calls++ + stub.value = value + switch stub.outcome { + case "authenticated": + return stub.session, nil + case "unauthenticated": + return identity.Session{}, identity.ErrUnauthenticated + case "infrastructure_error": + return identity.Session{}, errors.New("database secret must not leak") + default: + return identity.Session{}, errors.New("unexpected resolver call") + } +} + +type platformHTTPAuthContract struct { + Version int `json:"version"` + CookieName string `json:"cookieName"` + Cases []platformHTTPAuthCase `json:"cases"` +} + +type platformHTTPAuthCase struct { + Name string `json:"name"` + State AuthState `json:"state"` + Requirement string `json:"requirement"` + ResolverOutcome string `json:"resolverOutcome"` + Session *platformHTTPFixtureSession `json:"session"` + Expected struct { + Outcome string `json:"outcome"` + Status int `json:"status"` + Role string `json:"role"` + AuthMode string `json:"authMode"` + } `json:"expected"` +} + +type platformHTTPFixtureSession struct { + AuthMode string `json:"authMode"` + Role string `json:"role"` +} + +func loadPlatformHTTPAuthContract(t *testing.T) platformHTTPAuthContract { + t.Helper() + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate platform_auth_test.go") + } + raw, err := os.ReadFile(filepath.Join(filepath.Dir(filename), "..", "..", "..", "contracts", "auth", "platform-http-auth-v1.json")) + if err != nil { + t.Fatal(err) + } + var contract platformHTTPAuthContract + if err := json.Unmarshal(raw, &contract); err != nil { + t.Fatal(err) + } + if contract.Version != 1 || contract.CookieName != identity.SessionCookieName { + t.Fatalf("invalid contract header: %+v", contract) + } + return contract +} + +func fixturePlatformSession(input platformHTTPFixtureSession) identity.Session { + return identity.Session{ + Version: 1, + AuthMode: identity.AuthMode(input.AuthMode), + User: identity.User{ + ID: "fixture-user", Subject: "fixture-user", DisplayName: "Fixture User", + ClientID: "platform", Role: input.Role, Status: "active", + }, + } +} + +func assertPlatformAuthOutcome(t *testing.T, contractCase platformHTTPAuthCase, session identity.Session, err error) { + t.Helper() + switch contractCase.Expected.Outcome { + case "authenticated": + if err != nil { + t.Fatalf("Authorize error = %v", err) + } + if session.User.Role != contractCase.Expected.Role || string(session.AuthMode) != contractCase.Expected.AuthMode { + t.Fatalf("session role/mode = %q/%q, want %q/%q", session.User.Role, session.AuthMode, contractCase.Expected.Role, contractCase.Expected.AuthMode) + } + case "infrastructure_error": + var authErr *PlatformAuthError + if err == nil || errors.As(err, &authErr) { + t.Fatalf("error = %v, want non-auth infrastructure error", err) + } + default: + var authErr *PlatformAuthError + if !errors.As(err, &authErr) || authErr.Status != contractCase.Expected.Status || string(authErr.Kind) != contractCase.Expected.Outcome { + t.Fatalf("error = %#v, want %s/%d", err, contractCase.Expected.Outcome, contractCase.Expected.Status) + } + } +} diff --git a/backend/internal/publicapi/auth.go b/backend/internal/publicapi/auth.go new file mode 100644 index 0000000..f2edcb5 --- /dev/null +++ b/backend/internal/publicapi/auth.go @@ -0,0 +1,162 @@ +// Package publicapi authenticates public API clients and internal workers. +// Runtime configuration is parsed once and injected into an Authenticator; +// this package deliberately does not read process environment variables. +package publicapi + +import ( + "crypto/subtle" + "net/http" + "strings" +) + +const ownerPartLimit = 96 + +type PublicClient struct { + ID string + Key string +} + +type Config struct { + APIKeys string + InternalWorkerToken string + Production bool +} + +type AuthError struct { + Status int + Message string +} + +func (e *AuthError) Error() string { + return e.Message +} + +type Authenticator struct { + clients []PublicClient + internalWorkerToken string + production bool +} + +func NewAuthenticator(config Config) *Authenticator { + return &Authenticator{ + clients: ParseClients(config.APIKeys), + internalWorkerToken: strings.TrimSpace(config.InternalWorkerToken), + production: config.Production, + } +} + +func ParseClients(configured string) []PublicClient { + entries := strings.FieldsFunc(configured, func(character rune) bool { + return character == ',' || character == '\n' + }) + clients := make([]PublicClient, 0, len(entries)) + for _, rawEntry := range entries { + entry := strings.TrimSpace(rawEntry) + if entry == "" { + continue + } + + id := "default" + key := entry + if separator := strings.IndexByte(entry, ':'); separator >= 0 { + id = strings.TrimSpace(entry[:separator]) + key = strings.TrimSpace(entry[separator+1:]) + } + if id == "" || key == "" { + continue + } + clients = append(clients, PublicClient{ID: id, Key: key}) + } + return clients +} + +func (a *Authenticator) Authenticate(request *http.Request) (PublicClient, string, error) { + presented := publicCredential(request) + if presented == "" { + return PublicClient{}, "", &AuthError{Status: http.StatusUnauthorized, Message: "Missing API key."} + } + for _, client := range a.clients { + if secureEqual(client.Key, presented) { + return client, OwnerID(client.ID), nil + } + } + return PublicClient{}, "", &AuthError{Status: http.StatusUnauthorized, Message: "Invalid API key."} +} + +func (a *Authenticator) AssertInternalWorker(request *http.Request) error { + if a.internalWorkerToken == "" && !a.production { + return nil + } + if a.internalWorkerToken == "" { + return &AuthError{Status: http.StatusInternalServerError, Message: "Worker token is not configured."} + } + + presented := request.Header.Get("x-zhinian-worker-token") + if presented == "" { + presented = bearerToken(request) + } + if presented == "" || !secureEqual(a.internalWorkerToken, presented) { + return &AuthError{Status: http.StatusUnauthorized, Message: "Invalid worker token."} + } + return nil +} + +func OwnerID(id string) string { + part := sanitizeOwnerPart(id) + if part == "" { + part = "unknown" + } + return "api:" + part +} + +func publicCredential(request *http.Request) string { + if token := bearerToken(request); token != "" { + return token + } + return request.Header.Get("x-zhinian-api-key") +} + +func bearerToken(request *http.Request) string { + authorization := request.Header.Get("authorization") + separator := strings.IndexAny(authorization, " \t\r\n\v\f") + if separator <= 0 || !strings.EqualFold(authorization[:separator], "Bearer") { + return "" + } + if strings.TrimLeft(authorization[separator:], " \t\r\n\v\f") == authorization[separator:] { + return "" + } + return strings.TrimSpace(authorization[separator:]) +} + +func secureEqual(expected, presented string) bool { + if len(expected) != len(presented) { + return false + } + return subtle.ConstantTimeCompare([]byte(expected), []byte(presented)) == 1 +} + +func sanitizeOwnerPart(value string) string { + part := make([]byte, 0, min(len(value), ownerPartLimit)) + invalidRun := false + for _, character := range value { + if isOwnerCharacter(character) { + invalidRun = false + if len(part) < ownerPartLimit { + part = append(part, byte(character)) + } + continue + } + if !invalidRun && len(part) < ownerPartLimit { + part = append(part, '_') + } + invalidRun = true + } + return string(part) +} + +func isOwnerCharacter(character rune) bool { + return character >= 'A' && character <= 'Z' || + character >= 'a' && character <= 'z' || + character >= '0' && character <= '9' || + strings.ContainsRune("_.:@-", character) +} diff --git a/backend/internal/publicapi/auth_test.go b/backend/internal/publicapi/auth_test.go new file mode 100644 index 0000000..4ee68af --- /dev/null +++ b/backend/internal/publicapi/auth_test.go @@ -0,0 +1,171 @@ +package publicapi_test + +import ( + "encoding/json" + "net/http/httptest" + "os" + "testing" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/publicapi" +) + +type authErrorFixture struct { + Status int `json:"status"` + Message string `json:"message"` +} + +type authFixture struct { + Version int `json:"version"` + APIKeys string `json:"apiKeys"` + Clients []struct { + ID string `json:"id"` + Key string `json:"key"` + } `json:"clients"` + AuthenticationCases []struct { + Name string `json:"name"` + Headers map[string]string `json:"headers"` + Expected *struct { + Client struct { + ID string `json:"id"` + Key string `json:"key"` + } `json:"client"` + Owner string `json:"owner"` + } `json:"expected"` + Error *authErrorFixture `json:"error"` + } `json:"authenticationCases"` + OwnerCases []struct { + ID string `json:"id"` + Owner string `json:"owner"` + MaxPartLength int `json:"maxPartLength"` + } `json:"ownerCases"` + WorkerCases []struct { + Name string `json:"name"` + Production bool `json:"production"` + ConfiguredToken string `json:"configuredToken"` + Headers map[string]string `json:"headers"` + Allowed bool `json:"allowed"` + Error *authErrorFixture `json:"error"` + } `json:"workerCases"` +} + +func TestParseClientsMatchesSharedContract(t *testing.T) { + fixture := loadAuthFixture(t) + clients := publicapi.ParseClients(fixture.APIKeys) + if len(clients) != len(fixture.Clients) { + t.Fatalf("len(ParseClients()) = %d, want %d", len(clients), len(fixture.Clients)) + } + for index, want := range fixture.Clients { + if clients[index] != (publicapi.PublicClient{ID: want.ID, Key: want.Key}) { + t.Fatalf("client[%d] = %+v, want %+v", index, clients[index], want) + } + } +} + +func TestAuthenticateMatchesSharedContract(t *testing.T) { + fixture := loadAuthFixture(t) + authenticator := publicapi.NewAuthenticator(publicapi.Config{APIKeys: fixture.APIKeys}) + for _, testCase := range fixture.AuthenticationCases { + t.Run(testCase.Name, func(t *testing.T) { + request := httptest.NewRequest("GET", "/api/v1/generations", nil) + for name, value := range testCase.Headers { + request.Header.Set(name, value) + } + client, owner, err := authenticator.Authenticate(request) + if testCase.Error != nil { + assertAuthError(t, err, *testCase.Error) + return + } + if err != nil { + t.Fatalf("Authenticate() error = %v", err) + } + wantClient := publicapi.PublicClient{ID: testCase.Expected.Client.ID, Key: testCase.Expected.Client.Key} + if client != wantClient || owner != testCase.Expected.Owner { + t.Fatalf("Authenticate() = (%+v, %q), want (%+v, %q)", client, owner, wantClient, testCase.Expected.Owner) + } + }) + } +} + +func TestOwnerIDMatchesSharedContract(t *testing.T) { + fixture := loadAuthFixture(t) + for _, testCase := range fixture.OwnerCases { + t.Run(testCase.ID, func(t *testing.T) { + owner := publicapi.OwnerID(testCase.ID) + if owner != testCase.Owner { + t.Fatalf("OwnerID(%q) = %q, want %q", testCase.ID, owner, testCase.Owner) + } + if testCase.MaxPartLength > 0 && len(owner)-len("api:") != testCase.MaxPartLength { + t.Fatalf("owner part length = %d, want %d", len(owner)-len("api:"), testCase.MaxPartLength) + } + }) + } +} + +func TestAssertInternalWorkerMatchesSharedContract(t *testing.T) { + fixture := loadAuthFixture(t) + for _, testCase := range fixture.WorkerCases { + t.Run(testCase.Name, func(t *testing.T) { + authenticator := publicapi.NewAuthenticator(publicapi.Config{ + InternalWorkerToken: testCase.ConfiguredToken, + Production: testCase.Production, + }) + request := httptest.NewRequest("POST", "/api/internal/worker/tick", nil) + for name, value := range testCase.Headers { + request.Header.Set(name, value) + } + err := authenticator.AssertInternalWorker(request) + if testCase.Error != nil { + assertAuthError(t, err, *testCase.Error) + return + } + if err != nil { + t.Fatalf("AssertInternalWorker() error = %v", err) + } + }) + } +} + +func TestAuthenticatorUsesOnlyInjectedConfig(t *testing.T) { + t.Setenv("ZHINIAN_API_KEYS", "environment:must-not-be-read") + t.Setenv("ZHINIAN_INTERNAL_WORKER_TOKEN", "environment-worker-token") + t.Setenv("NODE_ENV", "production") + + authenticator := publicapi.NewAuthenticator(publicapi.Config{}) + request := httptest.NewRequest("GET", "/", nil) + request.Header.Set("authorization", "Bearer must-not-be-read") + _, _, err := authenticator.Authenticate(request) + assertAuthError(t, err, authErrorFixture{Status: 401, Message: "Invalid API key."}) + if err := authenticator.AssertInternalWorker(httptest.NewRequest("POST", "/", nil)); err != nil { + t.Fatalf("development bypass with injected zero config error = %v", err) + } +} + +func assertAuthError(t *testing.T, err error, want authErrorFixture) { + t.Helper() + if err == nil { + t.Fatal("error = nil, want typed authentication error") + } + authError, ok := err.(*publicapi.AuthError) + if !ok { + t.Fatalf("error type = %T, want *publicapi.AuthError", err) + } + if authError.Status != want.Status || authError.Message != want.Message || authError.Error() != want.Message { + t.Fatalf("error = %+v, want status=%d message=%q", authError, want.Status, want.Message) + } +} + +func loadAuthFixture(t *testing.T) authFixture { + t.Helper() + data, err := os.ReadFile("../../../contracts/auth/public-api-auth-v1.json") + if err != nil { + t.Fatalf("read public API auth fixture: %v", err) + } + var fixture authFixture + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatalf("decode public API auth fixture: %v", err) + } + if fixture.Version != 1 { + t.Fatalf("fixture version = %d, want 1", fixture.Version) + } + return fixture +} diff --git a/contracts/auth/platform-http-auth-v1.json b/contracts/auth/platform-http-auth-v1.json new file mode 100644 index 0000000..783ff21 --- /dev/null +++ b/contracts/auth/platform-http-auth-v1.json @@ -0,0 +1,89 @@ +{ + "version": 1, + "cookieName": "zhinian_session", + "cases": [ + { + "name": "optional unconfigured application access uses the local administrator", + "state": { "required": false, "configured": false }, + "requirement": "app", + "resolverOutcome": "not_called", + "expected": { "outcome": "authenticated", "status": 200, "role": "super_admin", "authMode": "admin" } + }, + { + "name": "required unconfigured authentication fails with service unavailable", + "state": { "required": true, "configured": false }, + "requirement": "app", + "resolverOutcome": "not_called", + "expected": { "outcome": "configuration_error", "status": 503 } + }, + { + "name": "required configured request without a cookie is unauthenticated", + "state": { "required": true, "configured": true }, + "requirement": "app", + "resolverOutcome": "not_called", + "expected": { "outcome": "unauthenticated", "status": 401 } + }, + { + "name": "an invalid session falls back locally only when authentication is optional", + "state": { "required": false, "configured": true }, + "requirement": "super_admin", + "resolverOutcome": "unauthenticated", + "expected": { "outcome": "authenticated", "status": 200, "role": "super_admin", "authMode": "admin" } + }, + { + "name": "database refreshed user is allowed application access", + "state": { "required": true, "configured": true }, + "requirement": "app", + "resolverOutcome": "authenticated", + "session": { "authMode": "user", "role": "user" }, + "expected": { "outcome": "authenticated", "status": 200, "role": "user", "authMode": "user" } + }, + { + "name": "ordinary user cannot access administration", + "state": { "required": true, "configured": true }, + "requirement": "admin", + "resolverOutcome": "authenticated", + "session": { "authMode": "user", "role": "user" }, + "expected": { "outcome": "forbidden", "status": 403 } + }, + { + "name": "organization administrator in admin mode can access administration", + "state": { "required": true, "configured": true }, + "requirement": "admin", + "resolverOutcome": "authenticated", + "session": { "authMode": "admin", "role": "organization_admin" }, + "expected": { "outcome": "authenticated", "status": 200, "role": "organization_admin", "authMode": "admin" } + }, + { + "name": "admin role without admin login mode cannot access administration", + "state": { "required": true, "configured": true }, + "requirement": "admin", + "resolverOutcome": "authenticated", + "session": { "authMode": "user", "role": "organization_admin" }, + "expected": { "outcome": "forbidden", "status": 403 } + }, + { + "name": "organization administrator cannot access super administration", + "state": { "required": true, "configured": true }, + "requirement": "super_admin", + "resolverOutcome": "authenticated", + "session": { "authMode": "admin", "role": "organization_admin" }, + "expected": { "outcome": "forbidden", "status": 403 } + }, + { + "name": "super administrator can access super administration", + "state": { "required": true, "configured": true }, + "requirement": "super_admin", + "resolverOutcome": "authenticated", + "session": { "authMode": "admin", "role": "super_admin" }, + "expected": { "outcome": "authenticated", "status": 200, "role": "super_admin", "authMode": "admin" } + }, + { + "name": "resolver infrastructure failure is not disguised as authentication denial", + "state": { "required": true, "configured": true }, + "requirement": "app", + "resolverOutcome": "infrastructure_error", + "expected": { "outcome": "infrastructure_error", "status": 500 } + } + ] +} diff --git a/contracts/auth/public-api-auth-v1.json b/contracts/auth/public-api-auth-v1.json new file mode 100644 index 0000000..f7db8a4 --- /dev/null +++ b/contracts/auth/public-api-auth-v1.json @@ -0,0 +1,91 @@ +{ + "version": 1, + "apiKeys": " alpha : alpha-secret ,\n bare-secret\n, invalid-only-id: , :invalid-only-key, beta: beta-secret ", + "clients": [ + { "id": "alpha", "key": "alpha-secret" }, + { "id": "default", "key": "bare-secret" }, + { "id": "beta", "key": "beta-secret" } + ], + "authenticationCases": [ + { + "name": "case insensitive bearer authenticates", + "headers": { "authorization": "bEaReR alpha-secret " }, + "expected": { "client": { "id": "alpha", "key": "alpha-secret" }, "owner": "api:alpha" } + }, + { + "name": "legacy api key header authenticates", + "headers": { "x-zhinian-api-key": "bare-secret" }, + "expected": { "client": { "id": "default", "key": "bare-secret" }, "owner": "api:default" } + }, + { + "name": "bearer takes precedence over legacy header", + "headers": { "authorization": "Bearer beta-secret", "x-zhinian-api-key": "alpha-secret" }, + "expected": { "client": { "id": "beta", "key": "beta-secret" }, "owner": "api:beta" } + }, + { + "name": "invalid bearer does not fall back to legacy header", + "headers": { "authorization": "Bearer wrong", "x-zhinian-api-key": "alpha-secret" }, + "error": { "status": 401, "message": "Invalid API key." } + }, + { + "name": "missing credentials are typed", + "headers": {}, + "error": { "status": 401, "message": "Missing API key." } + }, + { + "name": "different length credential is invalid", + "headers": { "authorization": "Bearer x" }, + "error": { "status": 401, "message": "Invalid API key." } + } + ], + "ownerCases": [ + { "id": "tenant / 中文!?", "owner": "api:tenant_" }, + { "id": "", "owner": "api:unknown" }, + { "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "owner": "api:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "maxPartLength": 96 }, + { "id": "safe.ID:@-42", "owner": "api:safe.ID:@-42" } + ], + "workerCases": [ + { + "name": "development permits an unconfigured token", + "production": false, + "configuredToken": " ", + "headers": {}, + "allowed": true + }, + { + "name": "production rejects an unconfigured token", + "production": true, + "configuredToken": " ", + "headers": {}, + "error": { "status": 500, "message": "Worker token is not configured." } + }, + { + "name": "worker header authenticates", + "production": true, + "configuredToken": " worker-secret ", + "headers": { "x-zhinian-worker-token": "worker-secret" }, + "allowed": true + }, + { + "name": "worker header takes precedence over bearer", + "production": true, + "configuredToken": "worker-secret", + "headers": { "x-zhinian-worker-token": "wrong", "authorization": "Bearer worker-secret" }, + "error": { "status": 401, "message": "Invalid worker token." } + }, + { + "name": "worker bearer is case insensitive", + "production": true, + "configuredToken": "worker-secret", + "headers": { "authorization": "BEARER worker-secret" }, + "allowed": true + }, + { + "name": "invalid worker token is typed", + "production": false, + "configuredToken": "worker-secret", + "headers": { "x-zhinian-worker-token": "x" }, + "error": { "status": 401, "message": "Invalid worker token." } + } + ] +} diff --git a/tests/auth-platform-http-auth-contract.test.ts b/tests/auth-platform-http-auth-contract.test.ts new file mode 100644 index 0000000..ad5de5e --- /dev/null +++ b/tests/auth-platform-http-auth-contract.test.ts @@ -0,0 +1,82 @@ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +import { hasAdminSessionAccess, hasSuperAdminAccess } from "@/lib/auth/permissions"; +import type { AuthSession } from "@/lib/auth/session"; + +type FixtureCase = { + name: string; + state: { required: boolean; configured: boolean }; + requirement: "app" | "admin" | "super_admin"; + resolverOutcome: "not_called" | "unauthenticated" | "authenticated" | "infrastructure_error"; + session?: { authMode: "user" | "admin"; role: "user" | "organization_admin" | "super_admin" }; + expected: { outcome: string; status: number; role?: string; authMode?: string }; +}; + +type Fixture = { version: 1; cookieName: string; cases: FixtureCase[] }; + +const fixtureUrl = new URL("../contracts/auth/platform-http-auth-v1.json", import.meta.url); + +describe("platform HTTP authorization v1 contract", () => { + it("freezes the current configuration fallback and error categories", async () => { + const fixture = JSON.parse(await readFile(fixtureUrl, "utf8")) as Fixture; + expect(fixture.version).toBe(1); + expect(fixture.cookieName).toBe("zhinian_session"); + + for (const contractCase of fixture.cases) { + expect(evaluateConfiguration(contractCase), contractCase.name).toEqual(contractCase.expected); + } + }); + + it("uses the current role and auth-mode permission helpers", async () => { + const fixture = JSON.parse(await readFile(fixtureUrl, "utf8")) as Fixture; + for (const contractCase of fixture.cases.filter((item) => item.session)) { + const session = fixtureSession(contractCase.session!); + const allowed = contractCase.requirement === "app" + ? true + : contractCase.requirement === "admin" + ? hasAdminSessionAccess(session) + : hasSuperAdminAccess(session.user); + expect(allowed, contractCase.name).toBe(contractCase.expected.outcome === "authenticated"); + } + }); +}); + +function evaluateConfiguration(contractCase: FixtureCase): FixtureCase["expected"] { + if (contractCase.resolverOutcome === "infrastructure_error") { + return { outcome: "infrastructure_error", status: 500 }; + } + if (contractCase.resolverOutcome === "authenticated") { + const session = fixtureSession(contractCase.session!); + const allowed = contractCase.requirement === "app" + || (contractCase.requirement === "admin" && hasAdminSessionAccess(session)) + || (contractCase.requirement === "super_admin" && hasSuperAdminAccess(session.user)); + return allowed + ? { outcome: "authenticated", status: 200, role: session.user.role, authMode: session.authMode } + : { outcome: "forbidden", status: 403 }; + } + if (!contractCase.state.required) { + return { outcome: "authenticated", status: 200, role: "super_admin", authMode: "admin" }; + } + if (!contractCase.state.configured) return { outcome: "configuration_error", status: 503 }; + return { outcome: "unauthenticated", status: 401 }; +} + +function fixtureSession(input: NonNullable): AuthSession { + return { + version: 1, + authMode: input.authMode, + issuedAt: 100, + expiresAt: 200, + user: { + id: "fixture-user", + subject: "fixture-user", + displayName: "Fixture User", + clientId: "platform", + role: input.role, + authorities: [], + scope: [], + }, + }; +} diff --git a/tests/public-api-auth-contract.test.ts b/tests/public-api-auth-contract.test.ts new file mode 100644 index 0000000..0db2587 --- /dev/null +++ b/tests/public-api-auth-contract.test.ts @@ -0,0 +1,112 @@ +import { readFile } from "node:fs/promises"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + PublicApiAuthError, + assertInternalWorkerToken, + authenticatePublicApiRequest, + getPublicApiClients, + publicApiOwnerId, + type PublicApiClient +} from "@/lib/server/public-api-auth"; + +type AuthError = { status: number; message: string }; +type HeaderMap = Record; +type PublicApiAuthFixture = { + version: 1; + apiKeys: string; + clients: PublicApiClient[]; + authenticationCases: Array<{ + name: string; + headers: HeaderMap; + expected?: { client: PublicApiClient; owner: string }; + error?: AuthError; + }>; + ownerCases: Array<{ id: string; owner: string; maxPartLength?: number }>; + workerCases: Array<{ + name: string; + production: boolean; + configuredToken: string; + headers: HeaderMap; + allowed?: boolean; + error?: AuthError; + }>; +}; + +const fixtureUrl = new URL("../contracts/auth/public-api-auth-v1.json", import.meta.url); +const environmentKeys = ["ZHINIAN_API_KEYS", "ZHINIAN_INTERNAL_WORKER_TOKEN", "NODE_ENV"] as const; +const originalEnvironment = new Map(environmentKeys.map((key) => [key, process.env[key]])); + +afterEach(() => { + for (const key of environmentKeys) { + const value = originalEnvironment.get(key); + if (value === undefined) Reflect.deleteProperty(process.env, key); + else Reflect.set(process.env, key, value); + } +}); + +async function loadFixture(): Promise { + return JSON.parse(await readFile(fixtureUrl, "utf8")) as PublicApiAuthFixture; +} + +function request(headers: HeaderMap): Request { + return new Request("https://api.example.test/api/v1/generations", { headers }); +} + +function expectTypedError(action: () => unknown, expected: AuthError) { + try { + action(); + throw new Error("expected PublicApiAuthError"); + } catch (error) { + expect(error).toBeInstanceOf(PublicApiAuthError); + expect(error).toMatchObject(expected); + } +} + +describe("public API authentication v1 cross-language contract", () => { + it("freezes comma/newline parsing, trimming, default ids, and empty filtering", async () => { + const fixture = await loadFixture(); + process.env.ZHINIAN_API_KEYS = fixture.apiKeys; + + expect(fixture.version).toBe(1); + expect(getPublicApiClients()).toEqual(fixture.clients); + }); + + it("freezes public credential transport, precedence, errors, client, and owner", async () => { + const fixture = await loadFixture(); + process.env.ZHINIAN_API_KEYS = fixture.apiKeys; + + for (const testCase of fixture.authenticationCases) { + if (testCase.error) { + expectTypedError(() => authenticatePublicApiRequest(request(testCase.headers)), testCase.error); + continue; + } + const client = authenticatePublicApiRequest(request(testCase.headers)); + expect(client, testCase.name).toEqual(testCase.expected?.client); + expect(publicApiOwnerId(client), testCase.name).toBe(testCase.expected?.owner); + } + }); + + it("sanitizes and bounds public owner ids", async () => { + const fixture = await loadFixture(); + for (const testCase of fixture.ownerCases) { + const owner = publicApiOwnerId(testCase.id); + expect(owner, testCase.id).toBe(testCase.owner); + if (testCase.maxPartLength) expect(owner.slice(4)).toHaveLength(testCase.maxPartLength); + } + }); + + it("freezes internal worker development bypass and production failure semantics", async () => { + const fixture = await loadFixture(); + for (const testCase of fixture.workerCases) { + Reflect.set(process.env, "NODE_ENV", testCase.production ? "production" : "development"); + Reflect.set(process.env, "ZHINIAN_INTERNAL_WORKER_TOKEN", testCase.configuredToken); + if (testCase.error) { + expectTypedError(() => assertInternalWorkerToken(request(testCase.headers)), testCase.error); + } else { + expect(() => assertInternalWorkerToken(request(testCase.headers)), testCase.name).not.toThrow(); + } + } + }); +});