diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index 7ed5eb5f..89ace080 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -902,6 +902,69 @@ definitions: service: $ref: '#/definitions/coremetrics.ServiceState' type: object + projects.APIKey: + properties: + created_at: + type: string + id: + type: string + name: + type: string + prefix: + type: string + project_id: + type: string + revoked_at: + type: string + type: object + projects.IssuedAPIKey: + properties: + created_at: + type: string + id: + type: string + key: + type: string + name: + type: string + prefix: + type: string + project_id: + type: string + revoked_at: + type: string + type: object + projects.KeyPage: + properties: + data: + items: + $ref: '#/definitions/projects.APIKey' + type: array + has_more: + type: boolean + type: object + projects.Page: + properties: + data: + items: + $ref: '#/definitions/projects.Project' + type: array + has_more: + type: boolean + type: object + projects.Project: + properties: + active_key_count: + type: integer + archived_at: + type: string + created_at: + type: string + id: + type: string + name: + type: string + type: object sandbox.ConfigurationDiscoveryInput: properties: configuration: @@ -990,23 +1053,6 @@ definitions: key_id: type: string type: object - store.IssuedProjectAPIKey: - properties: - created_at: - type: string - id: - type: string - key: - type: string - name: - type: string - prefix: - type: string - project_id: - type: string - revoked_at: - type: string - type: object store.ManagedSessionArchive: properties: environment_id: @@ -1039,52 +1085,6 @@ definitions: start: type: string type: object - store.Project: - properties: - active_key_count: - type: integer - archived_at: - type: string - created_at: - type: string - id: - type: string - name: - type: string - type: object - store.ProjectAPIKey: - properties: - created_at: - type: string - id: - type: string - name: - type: string - prefix: - type: string - project_id: - type: string - revoked_at: - type: string - type: object - store.ProjectAPIKeyPage: - properties: - data: - items: - $ref: '#/definitions/store.ProjectAPIKey' - type: array - has_more: - type: boolean - type: object - store.ProjectPage: - properties: - data: - items: - $ref: '#/definitions/store.Project' - type: array - has_more: - type: boolean - type: object store.RuntimeDeploymentView: properties: configuration: @@ -3673,7 +3673,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/store.ProjectPage' + $ref: '#/definitions/projects.Page' "400": description: Bad Request schema: @@ -3711,7 +3711,7 @@ paths: "201": description: Created schema: - $ref: '#/definitions/store.Project' + $ref: '#/definitions/projects.Project' "400": description: Bad Request schema: @@ -3755,7 +3755,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/store.Project' + $ref: '#/definitions/projects.Project' "400": description: Bad Request schema: @@ -3937,7 +3937,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/store.Project' + $ref: '#/definitions/projects.Project' "401": description: Unauthorized schema: @@ -4444,7 +4444,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/store.ProjectAPIKeyPage' + $ref: '#/definitions/projects.KeyPage' "400": description: Bad Request schema: @@ -4487,7 +4487,7 @@ paths: "201": description: Created schema: - $ref: '#/definitions/store.IssuedProjectAPIKey' + $ref: '#/definitions/projects.IssuedAPIKey' "400": description: Bad Request schema: diff --git a/services/core/IMPLEMENTATION.md b/services/core/IMPLEMENTATION.md index 2266ab8b..d6adda08 100644 --- a/services/core/IMPLEMENTATION.md +++ b/services/core/IMPLEMENTATION.md @@ -24,6 +24,7 @@ Domain owners, each with its PostgreSQL adapter under `internal/persistence/post - `agents` (`agentpg`): saved Agents, their configuration merge and bounds, and the encrypted model-provider bundle bound to each Agent. - `files` (`filepg`): source Files. +- `projects` (`projectpg`): Projects, Project API keys and key resolution for authentication. `projects` validates Project and key names by rune count, separately from the byte limits on node names, and decides key issuance over the share-locked Project so an archive and an issuance never both commit. - `vaults` (`vaultpg`): Vaults and Credentials, the encryption of Credential secrets, OAuth access-token refresh, and the MCP credential selection that Session creation freezes and the Dispatcher's `Credentials` resolves into a bearer token. - `environmenttemplates` (`templatepg`): Environment Templates, their validation and default network, their sealed setup, initial files, Skills and Plugins, and the resolved Template that Session creation composes into its Environment. - `modelconfiguration` (`modelconfigurationpg`): each Harness's deployment default model configuration and its last-use observations. @@ -188,7 +189,7 @@ The [managed lifecycle](../../docs/sandbox-provider.md#managed-lifecycle) descri ## Core administration errors, metrics and write provenance - Core error details are scoped by the `/core/v1` router's writer mark, never by a request path test. Write Core errors with `writeCoreError` and typed `CoreErrorDetails` values (string, number, null and string-array constructors); invalid or empty details are omitted as a whole. The mark preserves error observation, flushing and `http.ResponseController` access. A shared handler or a Core-looking path alone never changes a public or machine error envelope. Core authentication runs before operation configuration checks, and unknown paths keep their status and admission rules. When adding a code with details, document its fixed keys in `contracts/agents-api/core-errors.md`, and pass only safe Core-owned facts: never submitted values, secrets, native text or provider bodies. -- Operation validators keep their original error text, sentinel identity and validation precedence. Package-owned typed errors carry fixed field metadata; only the marked Core error mapper translates it into operation codes and safe bound or catalog details. Keep Project and key rune limits separate from node byte limits. Sandbox validation metadata travels through its store wrapper without changing transaction or provider authority. Public Session provider validation stays byte-for-byte unchanged; cover it with handler-level golden responses. The Core clients ignore malformed optional details and never retry a write. +- Operation validators keep their original error text, sentinel identity and validation precedence. Package-owned typed errors carry fixed field metadata; only the marked Core error mapper translates it into operation codes and safe bound or catalog details. Sandbox validation metadata travels through its store wrapper without changing transaction or provider authority. Public Session provider validation stays byte-for-byte unchanged; cover it with handler-level golden responses. The Core clients ignore malformed optional details and never retry a write. - Core metrics instrument the existing worker and job owners without changing scheduling, lease or retention behavior. Count `execution_unavailable` at the HTTP error writer, once per rejected response; never capture request or response bodies and never infer the count from other 503s or failed Turns. Process CPU, RSS and cgroup limits are sampled by the 30-second Core metrics loop into the same bounded in-memory ring; the first CPU interval and restart gaps stay null, and host usage never substitutes for process usage. Root Turn history is queried read-only from PostgreSQL with native timestamps. Builds inject the source commit with `-ldflags` into `main.buildRevision`. Keep the response shape aligned with `packages/agents-client/src/core-metrics.ts`. - Public resource writes carry the authenticated key's provenance separately from the execution principal. Record the operation and any creation ownership with `auditpg.RecordWriteAudit` in the business transaction, never in response middleware or an asynchronous queue; a failed record rolls back the write. Internal lifecycle and refresh work never acquires public provenance, and retries never replace ownership. Environment uploads persist the safe request origin before dispatch and record success with the confirmed Runtime receipt, not the native filesystem call. Never put payloads, paths or secrets in audit metadata. Do not confuse key identity with the Session creator identity used for retries. - Administrator writes reuse the public resource deletion and serialization code and record their administrator audit entry with `auditpg.RecordAdminMutation`, or `RecordDeploymentMutation` for a deployment-wide write, in the same transaction. Administrator provenance takes precedence over a key's. diff --git a/services/core/cmd/server/auth_fixture_test.go b/services/core/cmd/server/auth_fixture_test.go index ab4deda6..ae456b89 100644 --- a/services/core/cmd/server/auth_fixture_test.go +++ b/services/core/cmd/server/auth_fixture_test.go @@ -2,26 +2,27 @@ package main import ( "context" + "crypto/sha256" "encoding/hex" "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/google/uuid" ) type testAPIKey struct{ Name, TokenSHA256, TenantID, OrganizationID, ProjectID, SubjectKind, SubjectID string } -type fixtureKeyResolver map[string]store.ProjectAPIKeyBinding +type fixtureKeyResolver map[[sha256.Size]byte]projects.KeyBinding -func (f fixtureKeyResolver) ResolveProjectAPIKey(_ context.Context, digest string) (store.ProjectAPIKeyBinding, error) { +func (f fixtureKeyResolver) ResolveAPIKey(_ context.Context, digest [sha256.Size]byte) (projects.KeyBinding, error) { if b, ok := f[digest]; ok { return b, nil } - return store.ProjectAPIKeyBinding{}, store.ErrNotFound + return projects.KeyBinding{}, projects.ErrNotFound } // newTestAuthenticator binds each key's digest to its Principal, as the Project -// store does. +// reader does. func newTestAuthenticator(t testing.TB, keys []testAPIKey) fixtureKeyResolver { t.Helper() resolver := fixtureKeyResolver{} @@ -30,13 +31,15 @@ func newTestAuthenticator(t testing.TB, keys []testAPIKey) fixtureKeyResolver { if err := p.Validate(); err != nil { t.Fatalf("invalid fixture principal: %v", err) } - if digest, err := hex.DecodeString(k.TokenSHA256); err != nil || len(digest) != 32 { + raw, err := hex.DecodeString(k.TokenSHA256) + if err != nil || len(raw) != sha256.Size { t.Fatalf("invalid fixture digest %q", k.TokenSHA256) } - if _, exists := resolver[k.TokenSHA256]; exists { + digest := [sha256.Size]byte(raw) + if _, exists := resolver[digest]; exists { t.Fatalf("duplicate fixture digest %q", k.TokenSHA256) } - resolver[k.TokenSHA256] = store.ProjectAPIKeyBinding{Key: store.ProjectAPIKey{ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte(k.TokenSHA256)).String(), Name: k.Name, Prefix: "pc_" + k.TokenSHA256[:8]}, Principal: p} + resolver[digest] = projects.KeyBinding{Key: projects.APIKey{ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte(k.TokenSHA256)).String(), Name: k.Name, Prefix: "pc_" + k.TokenSHA256[:8]}, Principal: p} } return resolver } diff --git a/services/core/cmd/server/http_routes_test.go b/services/core/cmd/server/http_routes_test.go index a56fe940..4d506112 100644 --- a/services/core/cmd/server/http_routes_test.go +++ b/services/core/cmd/server/http_routes_test.go @@ -3,6 +3,7 @@ package main import ( "bufio" "context" + "crypto/sha256" "fmt" "io" "net" @@ -15,8 +16,8 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -65,14 +66,14 @@ func TestServerHandlerRoutesCanonicalPaths(t *testing.T) { } } -// trapProjects resolves the fixture Project keys; every other call panics. -type trapProjects struct { - api.Projects +// trapProjectsReader resolves the fixture Project keys; every other call panics. +type trapProjectsReader struct { + api.ProjectsReader keys fixtureKeyResolver } -func (p trapProjects) ResolveProjectAPIKey(ctx context.Context, digest string) (store.ProjectAPIKeyBinding, error) { - return p.keys.ResolveProjectAPIKey(ctx, digest) +func (p trapProjectsReader) ResolveAPIKey(ctx context.Context, digest [sha256.Size]byte) (projects.KeyBinding, error) { + return p.keys.ResolveAPIKey(ctx, digest) } // daemonComposition serves the real API handler beside sentinel daemon routes. @@ -87,7 +88,7 @@ func daemonComposition(t testing.TB) http.Handler { } apiHandler, err := api.NewHandler(api.Dependencies{ Engine: "codex", CoreKeys: admin, InstallationBindings: struct{ api.InstallationBindings }{}, - Projects: trapProjects{keys: keys}, + Projects: struct{ api.Projects }{}, ProjectsReader: trapProjectsReader{keys: keys}, ModelProviders: struct{ api.ModelProviders }{}, ModelProvidersReader: struct{ api.ModelProvidersReader }{}, Vaults: struct{ api.Vaults }{}, VaultsReader: struct{ api.VaultsReader }{}, Files: struct{ api.Files }{}, FilesReader: struct{ api.FilesReader }{}, diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go index df2ea7cb..f9990f8d 100644 --- a/services/core/cmd/server/main.go +++ b/services/core/cmd/server/main.go @@ -45,9 +45,11 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/filepg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/modelconfigurationpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/projectpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/skillpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/templatepg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/vaultpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtime" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeenrollment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" @@ -146,6 +148,11 @@ func run() error { if err != nil { return err } + projectStore := projectpg.New(units) + projectService, err := projects.NewService(projectStore) + if err != nil { + return err + } installation, err := installationFacts(public) if err != nil { return err @@ -222,7 +229,7 @@ func run() error { return err } } - if err := api.ValidateCredentialSeparation(ctx, keyAdmin, executionStore); err != nil { + if err := api.ValidateCredentialSeparation(ctx, keyAdmin, projectStore); err != nil { return err } historyResolver, err := historystoreresolver.NewResolver(executionStore) @@ -338,7 +345,7 @@ func run() error { deps := api.Dependencies{ Engine: engine, Harnesses: kinds, CoreKeys: keyAdmin, Installation: installation, InstallationBindings: executionStore, - Projects: executionStore, + Projects: projectService, ProjectsReader: projectStore, ModelProviders: modelConfigurationService, ModelProvidersReader: modelConfigurationStore, Vaults: vaultService, VaultsReader: vaultStore, Skills: skillService, SkillsReader: skillStore, diff --git a/services/core/internal/api/admin_resources_test.go b/services/core/internal/api/admin_resources_test.go index 174fb205..dee33e1d 100644 --- a/services/core/internal/api/admin_resources_test.go +++ b/services/core/internal/api/admin_resources_test.go @@ -11,6 +11,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/agents" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -18,13 +19,13 @@ import ( const managementProjectID = "22222222-2222-4222-8222-222222222222" // managementProject resolves managementProjectID to key's Project. -func managementProject(key APIKey) func(context.Context, string) (store.ProjectBinding, error) { +func managementProject(key APIKey) func(context.Context, string) (projects.Binding, error) { principal := identity.Principal{ProjectScope: identity.ProjectScope{TenantID: key.TenantID, OrganizationID: key.OrganizationID, ProjectID: key.ProjectID}, SubjectKind: key.SubjectKind, SubjectID: key.SubjectID} - return func(_ context.Context, id string) (store.ProjectBinding, error) { + return func(_ context.Context, id string) (projects.Binding, error) { if id != managementProjectID { - return store.ProjectBinding{}, store.ErrNotFound + return projects.Binding{}, projects.ErrNotFound } - return store.ProjectBinding{Project: store.Project{ID: id, TenantID: principal.TenantID}, Principal: principal}, nil + return projects.Binding{Project: projects.Project{ID: id, TenantID: principal.TenantID}, Principal: principal}, nil } } @@ -33,8 +34,8 @@ func managementProject(key APIKey) func(context.Context, string) (store.ProjectB func managementFakes(t testing.TB, key APIKey) (Dependencies, *testFakes) { t.Helper() deps, fakes := testDependencies(t) - fakes.projects.resolveProjectAPIKey = projectKeys(t, key).ResolveProjectAPIKey - fakes.projects.getProject = managementProject(key) + fakes.projectsReader.resolveAPIKey = projectKeys(t, key).ResolveAPIKey + fakes.projectsReader.getProject = managementProject(key) return deps, fakes } diff --git a/services/core/internal/api/admin_runtime.go b/services/core/internal/api/admin_runtime.go index 0506e390..393e6558 100644 --- a/services/core/internal/api/admin_runtime.go +++ b/services/core/internal/api/admin_runtime.go @@ -5,6 +5,7 @@ import ( "net/http" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" ) @@ -57,17 +58,17 @@ func (h *Handler) adminRuntimeObservations(w http.ResponseWriter, r *http.Reques projectByTenant := map[string]string{} cursor := "" for { - projects, err := h.Projects.ListProjects(ctx, cursor, 100, true) + page, err := h.ProjectsReader.ListProjects(ctx, projects.ListQuery{After: cursor, Limit: projects.MaxListLimit, Ascending: true}) if err != nil { - writeStoreError(w, r, err) + writeProjectsError(w, r, err) return } - for _, project := range projects.Data { + for _, project := range page.Data { tenants = append(tenants, project.TenantID) projectByTenant[project.TenantID] = project.ID cursor = project.ID } - if !projects.HasMore { + if !page.HasMore { break } } diff --git a/services/core/internal/api/admin_runtime_test.go b/services/core/internal/api/admin_runtime_test.go index 5590d9cc..4507c074 100644 --- a/services/core/internal/api/admin_runtime_test.go +++ b/services/core/internal/api/admin_runtime_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" @@ -31,11 +32,11 @@ const adminRuntimeObservationsPath = "/core/v1/sandbox/runtime-observations" // adminRuntimeFixture serves the administrator observation list over one // Project per tenant and the given Session targets. -func adminRuntimeFixture(t *testing.T, projects []store.Project, targets []store.AdminRuntimeTarget, service RuntimeObservations) (http.Handler, *adminRuntimeTargets) { +func adminRuntimeFixture(t *testing.T, catalog []projects.Project, targets []store.AdminRuntimeTarget, service RuntimeObservations) (http.Handler, *adminRuntimeTargets) { t.Helper() deps, fakes := managementFakes(t, callerBinding()) - fakes.projects.listProjects = func(context.Context, string, int, bool) (store.ProjectPage, error) { - return store.ProjectPage{Data: projects}, nil + fakes.projectsReader.listProjects = func(context.Context, projects.ListQuery) (projects.Page, error) { + return projects.Page{Data: catalog}, nil } management := &adminRuntimeTargets{page: store.AdminRuntimeTargetPage{Data: targets, HasMore: true}} fakes.admin.listAdminRuntimeTargets = management.ListAdminRuntimeTargets @@ -71,10 +72,10 @@ func unsupportedObservation(session string, at time.Time) runtimeobs.Observation func TestAdminRuntimeObservationListKeepsTargetOrderAndProjects(t *testing.T) { now := time.Date(2026, 9, 22, 8, 0, 0, 0, time.UTC) - projects := []store.Project{{ID: uuid.NewString(), TenantID: uuid.NewString()}, {ID: uuid.NewString(), TenantID: uuid.NewString()}} + catalog := []projects.Project{{ID: uuid.NewString(), TenantID: uuid.NewString()}, {ID: uuid.NewString(), TenantID: uuid.NewString()}} var targets []store.AdminRuntimeTarget for index := range 3 { - targets = append(targets, store.AdminRuntimeTarget{SessionID: uuid.NewString(), TenantID: projects[index%2].TenantID}) + targets = append(targets, store.AdminRuntimeTarget{SessionID: uuid.NewString(), TenantID: catalog[index%2].TenantID}) } service := runtimeObservationServiceFunc(func(_ context.Context, tenant, session string) (runtimeobs.Observation, error) { if !slices.Contains(targets, store.AdminRuntimeTarget{SessionID: session, TenantID: tenant}) { @@ -82,21 +83,21 @@ func TestAdminRuntimeObservationListKeepsTargetOrderAndProjects(t *testing.T) { } return unsupportedObservation(session, now), nil }) - handler, management := adminRuntimeFixture(t, projects, targets, service) + handler, management := adminRuntimeFixture(t, catalog, targets, service) response := runtimeObservationRequest(handler, adminRuntimeObservationsPath+"?after=cursor&limit=3&order=asc") var page AdminRuntimeObservationList if response.Code != http.StatusOK || json.Unmarshal(response.Body.Bytes(), &page) != nil { t.Fatalf("list returned %d: %s", response.Code, response.Body) } - if !slices.Equal(management.tenants, []string{projects[0].TenantID, projects[1].TenantID}) || management.after != "cursor" || management.limit != 3 || !management.ascending { + if !slices.Equal(management.tenants, []string{catalog[0].TenantID, catalog[1].TenantID}) || management.after != "cursor" || management.limit != 3 || !management.ascending { t.Fatalf("pagination binding was not preserved: %+v", management) } if len(page.Data) != len(targets) || !page.HasMore || page.FirstID == nil || *page.FirstID != targets[0].SessionID || page.LastID == nil || *page.LastID != targets[2].SessionID { t.Fatalf("invalid page: %s", response.Body) } for index, item := range page.Data { - if item.Observation.ID != targets[index].SessionID || item.ProjectID != projects[index%2].ID { + if item.Observation.ID != targets[index].SessionID || item.ProjectID != catalog[index%2].ID { t.Fatalf("concurrent collection reordered or mislabelled the page: %s", response.Body) } } @@ -105,7 +106,7 @@ func TestAdminRuntimeObservationListKeepsTargetOrderAndProjects(t *testing.T) { // The page is read in one batch with the shared concurrency and per-source bounds. func TestAdminRuntimeObservationListBoundsCollection(t *testing.T) { now := time.Date(2026, 9, 22, 8, 0, 0, 0, time.UTC) - project := store.Project{ID: uuid.NewString(), TenantID: uuid.NewString()} + project := projects.Project{ID: uuid.NewString(), TenantID: uuid.NewString()} var targets []store.AdminRuntimeTarget for range 20 { targets = append(targets, store.AdminRuntimeTarget{SessionID: uuid.NewString(), TenantID: project.TenantID}) @@ -114,7 +115,7 @@ func TestAdminRuntimeObservationListBoundsCollection(t *testing.T) { service := runtimeObservationPageRecorder{options: &options, runtimeObservationServiceFunc: func(_ context.Context, _, session string) (runtimeobs.Observation, error) { return unsupportedObservation(session, now), nil }} - handler, _ := adminRuntimeFixture(t, []store.Project{project}, targets, service) + handler, _ := adminRuntimeFixture(t, []projects.Project{project}, targets, service) if response := runtimeObservationRequest(handler, adminRuntimeObservationsPath+"?limit=20"); response.Code != http.StatusOK { t.Fatalf("list returned %d: %s", response.Code, response.Body) } @@ -125,7 +126,7 @@ func TestAdminRuntimeObservationListBoundsCollection(t *testing.T) { func TestAdminRuntimeObservationListRejectsWholePageOnIntegrityFailure(t *testing.T) { now := time.Date(2026, 9, 22, 8, 0, 0, 0, time.UTC) - project := store.Project{ID: uuid.NewString(), TenantID: uuid.NewString()} + project := projects.Project{ID: uuid.NewString(), TenantID: uuid.NewString()} valid, invalid := uuid.NewString(), uuid.NewString() service := runtimeObservationServiceFunc(func(_ context.Context, _, session string) (runtimeobs.Observation, error) { if session == invalid { @@ -133,7 +134,7 @@ func TestAdminRuntimeObservationListRejectsWholePageOnIntegrityFailure(t *testin } return unsupportedObservation(session, now), nil }) - handler, _ := adminRuntimeFixture(t, []store.Project{project}, []store.AdminRuntimeTarget{{SessionID: valid, TenantID: project.TenantID}, {SessionID: invalid, TenantID: project.TenantID}}, service) + handler, _ := adminRuntimeFixture(t, []projects.Project{project}, []store.AdminRuntimeTarget{{SessionID: valid, TenantID: project.TenantID}, {SessionID: invalid, TenantID: project.TenantID}}, service) response := runtimeObservationRequest(handler, adminRuntimeObservationsPath+"?limit=2") var envelope struct { diff --git a/services/core/internal/api/admin_summary.go b/services/core/internal/api/admin_summary.go index 28d5192a..fcd1f60b 100644 --- a/services/core/internal/api/admin_summary.go +++ b/services/core/internal/api/admin_summary.go @@ -7,6 +7,7 @@ import ( "time" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -101,24 +102,24 @@ func (h *Handler) adminSummary(w http.ResponseWriter, r *http.Request) { writeStoreError(w, r, store.ErrInvalidInput) return } - var projects store.ProjectPage + var page projects.Page if projectID := r.URL.Query().Get("project_id"); projectID != "" { if options.after != "" { writeStoreError(w, r, store.ErrInvalidInput) return } - var binding store.ProjectBinding - binding, err = h.Projects.GetProject(ctx, projectID) - projects = store.ProjectPage{Data: []store.Project{binding.Project}} + var binding projects.Binding + binding, err = h.ProjectsReader.GetProject(ctx, projectID) + page = projects.Page{Data: []projects.Project{binding.Project}} } else { - projects, err = h.Projects.ListProjects(ctx, options.after, options.limit, options.ascending) + page, err = h.ProjectsReader.ListProjects(ctx, projects.ListQuery{After: options.after, Limit: options.limit, Ascending: options.ascending}) } if err != nil { - writeStoreError(w, r, err) + writeProjectsError(w, r, err) return } - response := AdminSummaryResponse{Data: []AdminSummaryRow{}, HasMore: projects.HasMore} - for _, project := range projects.Data { + response := AdminSummaryResponse{Data: []AdminSummaryRow{}, HasMore: page.HasMore} + for _, project := range page.Data { groups := map[string]*AdminSummaryRow{} if group == "project" { groups[""] = &AdminSummaryRow{ProjectID: project.ID} @@ -192,8 +193,8 @@ func (h *Handler) adminSummary(w http.ResponseWriter, r *http.Request) { response.Data = append(response.Data, *row) } } - if projects.HasMore && len(projects.Data) > 0 { - response.NextCursor = projects.Data[len(projects.Data)-1].ID + if page.HasMore && len(page.Data) > 0 { + response.NextCursor = page.Data[len(page.Data)-1].ID } writeJSON(w, http.StatusOK, response) } diff --git a/services/core/internal/api/auth.go b/services/core/internal/api/auth.go index f98d6fcd..768aba7b 100644 --- a/services/core/internal/api/auth.go +++ b/services/core/internal/api/auth.go @@ -3,7 +3,6 @@ package api import ( "context" "crypto/sha256" - "encoding/hex" "errors" "net/http" "strings" @@ -13,7 +12,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" ) func projectBearerDigest(r *http.Request) ([sha256.Size]byte, bool) { @@ -44,8 +43,8 @@ func (h *Handler) resolveCaller(r *http.Request) (identity.Principal, writeaudit } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() - binding, err := h.Projects.ResolveProjectAPIKey(ctx, hex.EncodeToString(digest[:])) - if errors.Is(err, store.ErrNotFound) { + binding, err := h.ProjectsReader.ResolveAPIKey(ctx, digest) + if errors.Is(err, projects.ErrNotFound) { return identity.Principal{}, writeaudit.Source{}, false, nil } if err != nil { diff --git a/services/core/internal/api/auth_fixture_test.go b/services/core/internal/api/auth_fixture_test.go index a09652b9..fb5bdae9 100644 --- a/services/core/internal/api/auth_fixture_test.go +++ b/services/core/internal/api/auth_fixture_test.go @@ -2,25 +2,26 @@ package api import ( "context" + "crypto/sha256" "encoding/hex" "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/google/uuid" ) type APIKey struct{ Name, TokenSHA256, TenantID, OrganizationID, ProjectID, SubjectKind, SubjectID string } -// fixtureKeyResolver resolves Project key digests the way the Project store -// does. Tests assign its ResolveProjectAPIKey to fakeProjects. -type fixtureKeyResolver map[string]store.ProjectAPIKeyBinding +// fixtureKeyResolver resolves Project key digests the way the Project reader +// does. Tests assign its ResolveAPIKey to fakeProjectsReader. +type fixtureKeyResolver map[[sha256.Size]byte]projects.KeyBinding -func (f fixtureKeyResolver) ResolveProjectAPIKey(_ context.Context, digest string) (store.ProjectAPIKeyBinding, error) { +func (f fixtureKeyResolver) ResolveAPIKey(_ context.Context, digest [sha256.Size]byte) (projects.KeyBinding, error) { if b, ok := f[digest]; ok { return b, nil } - return store.ProjectAPIKeyBinding{}, store.ErrNotFound + return projects.KeyBinding{}, projects.ErrNotFound } // projectKeys binds each key's digest to its Principal. @@ -32,13 +33,25 @@ func projectKeys(t testing.TB, keys ...APIKey) fixtureKeyResolver { if err := p.Validate(); err != nil { t.Fatal(err) } - if digest, err := hex.DecodeString(k.TokenSHA256); err != nil || len(digest) != 32 { + raw, err := hex.DecodeString(k.TokenSHA256) + if err != nil || len(raw) != sha256.Size { t.Fatalf("invalid fixture digest %q", k.TokenSHA256) } - if _, exists := resolver[k.TokenSHA256]; exists { + digest := [sha256.Size]byte(raw) + if _, exists := resolver[digest]; exists { t.Fatalf("duplicate fixture digest %q", k.TokenSHA256) } - resolver[k.TokenSHA256] = store.ProjectAPIKeyBinding{Key: store.ProjectAPIKey{ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte(k.TokenSHA256)).String(), Name: k.Name, Prefix: "pc_" + k.TokenSHA256[:8]}, Principal: p} + resolver[digest] = projects.KeyBinding{Key: projects.APIKey{ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte(k.TokenSHA256)).String(), Name: k.Name, Prefix: "pc_" + k.TokenSHA256[:8]}, Principal: p} } return resolver } + +// projectKeyBinding returns the binding projectKeys stores for key. +func projectKeyBinding(t testing.TB, key APIKey) projects.KeyBinding { + t.Helper() + for _, binding := range projectKeys(t, key) { + return binding + } + t.Fatal("fixture key has no binding") + return projects.KeyBinding{} +} diff --git a/services/core/internal/api/auth_test.go b/services/core/internal/api/auth_test.go index 7876b15c..3246ed48 100644 --- a/services/core/internal/api/auth_test.go +++ b/services/core/internal/api/auth_test.go @@ -21,7 +21,7 @@ func TestCallerPrincipalHeadersAndKeyRotation(t *testing.T) { rotated.TokenSHA256 = runtimedevice.HashCredential("rotated") peer.TokenSHA256 = runtimedevice.HashCredential("peer") deps, fakes := testDependencies(t) - fakes.projects.resolveProjectAPIKey = projectKeys(t, key, rotated, peer).ResolveProjectAPIKey + fakes.projectsReader.resolveAPIKey = projectKeys(t, key, rotated, peer).ResolveAPIKey for _, test := range []struct { name, token, subject string headers http.Header diff --git a/services/core/internal/api/core_store_validation_test.go b/services/core/internal/api/core_store_validation_test.go index a437a5c2..a815a237 100644 --- a/services/core/internal/api/core_store_validation_test.go +++ b/services/core/internal/api/core_store_validation_test.go @@ -10,13 +10,13 @@ import ( "strings" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestCoreStoreValidationFieldsAndPublicFallback(t *testing.T) { s := &store.Store{} - _, nameErr := s.CreateProject(context.Background(), managementProjectID, strings.Repeat("private-name", 20)) upperCapacityErr := s.UpdateRuntimeNode(context.Background(), managementProjectID, store.RuntimeNodeUpdate{Name: "node", MaxActive: 1000001, MaxRetained: 8}) capacityErr := s.UpdateRuntimeNode(context.Background(), managementProjectID, store.RuntimeNodeUpdate{Name: "node", MaxActive: 0}) _, resourceErr := store.SandboxSetupForSelection(managementProjectID, store.SandboxDeploymentSetupRequest{Provider: "docker", DeploymentSpec: sandbox.DeploymentSpec{}}) @@ -26,19 +26,24 @@ func TestCoreStoreValidationFieldsAndPublicFallback(t *testing.T) { code, param string details map[string]any publicMessage, publicCode string + write func(http.ResponseWriter, *http.Request, error) }{ - {&sandbox.ValidationError{Param: "resources", Message: "E2B template build resources are outside the supported sandbox limits; select another build"}, "invalid_sandbox_configuration", "resources", nil, "E2B template build resources are outside the supported sandbox limits; select another build", "invalid_sandbox_configuration"}, - {nameErr, "invalid_name", "name", map[string]any{"max_length": float64(128)}, "Invalid resource identifier or request limits.", "invalid_request"}, - {upperCapacityErr, "invalid_node_capacity", "max_active", map[string]any{"min": float64(1), "max": float64(1000000)}, "Invalid resource identifier or request limits.", "invalid_request"}, - {capacityErr, "invalid_node_capacity", "max_active", map[string]any{"min": float64(1), "max": float64(1000000)}, "Invalid resource identifier or request limits.", "invalid_request"}, - {resourceErr, "invalid_sandbox_configuration", "resources.cpus", map[string]any{"min": float64(1), "max": float64(255)}, "invalid sandbox configuration: cpus must be 1..255 and memory_mib must be 512..1048576", "invalid_sandbox_configuration"}, - {runtimeErr, "invalid_sandbox_configuration", "runtime", nil, "invalid sandbox configuration: managed nodes require a pinned Runtime release", "invalid_sandbox_configuration"}, + {&sandbox.ValidationError{Param: "resources", Message: "E2B template build resources are outside the supported sandbox limits; select another build"}, "invalid_sandbox_configuration", "resources", nil, "E2B template build resources are outside the supported sandbox limits; select another build", "invalid_sandbox_configuration", nil}, + {&projects.NameError{MaxLength: projects.ProjectNameMaxLength}, "invalid_name", "name", map[string]any{"max_length": float64(128)}, "Invalid resource identifier or request limits.", "invalid_request", writeProjectsError}, + {upperCapacityErr, "invalid_node_capacity", "max_active", map[string]any{"min": float64(1), "max": float64(1000000)}, "Invalid resource identifier or request limits.", "invalid_request", nil}, + {capacityErr, "invalid_node_capacity", "max_active", map[string]any{"min": float64(1), "max": float64(1000000)}, "Invalid resource identifier or request limits.", "invalid_request", nil}, + {resourceErr, "invalid_sandbox_configuration", "resources.cpus", map[string]any{"min": float64(1), "max": float64(255)}, "invalid sandbox configuration: cpus must be 1..255 and memory_mib must be 512..1048576", "invalid_sandbox_configuration", nil}, + {runtimeErr, "invalid_sandbox_configuration", "runtime", nil, "invalid sandbox configuration: managed nodes require a pinned Runtime release", "invalid_sandbox_configuration", nil}, } { if tc.err == nil { t.Fatal("missing validator error") } + write := tc.write + if write == nil { + write = func(w http.ResponseWriter, r *http.Request, err error) { writeStoreError(w, r, err) } + } for _, core := range []bool{false, true} { - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeStoreError(w, r, fmt.Errorf("wrapped: %w", tc.err)) })) + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { write(w, r, fmt.Errorf("wrapped: %w", tc.err)) })) if core { handler = coreErrorResponses(handler) } diff --git a/services/core/internal/api/core_validation_errors.go b/services/core/internal/api/core_validation_errors.go index 2b0d5e82..03012481 100644 --- a/services/core/internal/api/core_validation_errors.go +++ b/services/core/internal/api/core_validation_errors.go @@ -9,6 +9,8 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) +const invalidNameMessage = "The name exceeds its length limit or contains invalid characters." + // writeCoreValidationError is deliberately gated by the router marker. Shared // store validators must retain the public and machine routes' existing errors. func writeCoreValidationError(w http.ResponseWriter, err error) bool { @@ -20,7 +22,7 @@ func writeCoreValidationError(w http.ResponseWriter, err error) bool { switch { case errors.As(err, &field): details := CoreErrorDetails{"max_length": CoreErrorNumber(float64(field.MaxLength))} - message := "The name exceeds its length limit or contains invalid characters." + message := invalidNameMessage if field.Code == "invalid_node_capacity" { details = CoreErrorDetails{"min": CoreErrorNumber(1), "max": CoreErrorNumber(1000000)} message = "Node capacity must be positive, at most 1000000, and max_retained must be at least max_active." diff --git a/services/core/internal/api/dependencies.go b/services/core/internal/api/dependencies.go index 7a67cedc..53d46e34 100644 --- a/services/core/internal/api/dependencies.go +++ b/services/core/internal/api/dependencies.go @@ -30,6 +30,7 @@ type Dependencies struct { InstallationBindings InstallationBindings Projects Projects + ProjectsReader ProjectsReader Vaults Vaults VaultsReader VaultsReader ModelProviders ModelProviders @@ -116,7 +117,8 @@ func (d Dependencies) validate() error { return errors.New("api: CoreKeys is required") } if err := required( - field{"InstallationBindings", d.InstallationBindings}, field{"Projects", d.Projects}, + field{"InstallationBindings", d.InstallationBindings}, + field{"Projects", d.Projects}, field{"ProjectsReader", d.ProjectsReader}, field{"Vaults", d.Vaults}, field{"VaultsReader", d.VaultsReader}, field{"ModelProviders", d.ModelProviders}, field{"ModelProvidersReader", d.ModelProvidersReader}, field{"Files", d.Files}, field{"FilesReader", d.FilesReader}, diff --git a/services/core/internal/api/dependencies_test.go b/services/core/internal/api/dependencies_test.go index 1903fdce..c7d2e935 100644 --- a/services/core/internal/api/dependencies_test.go +++ b/services/core/internal/api/dependencies_test.go @@ -16,6 +16,7 @@ const testExecutorURL = "wss://core.example/api/v1/agent-daemon/ws" // testFakes holds one strict fake per Dependencies area. type testFakes struct { projects *fakeProjects + projectsReader *fakeProjectsReader vaults *fakeVaults vaultsReader *fakeVaultsReader modelProviders *fakeModelProviders @@ -60,7 +61,7 @@ type testFakes struct { func testDependencies(t testing.TB) (Dependencies, *testFakes) { t.Helper() f := &testFakes{ - projects: &fakeProjects{t: t}, + projects: &fakeProjects{t: t}, projectsReader: &fakeProjectsReader{t: t}, modelProviders: &fakeModelProviders{t: t}, modelProvidersReader: &fakeModelProvidersReader{t: t}, vaults: &fakeVaults{t: t}, vaultsReader: &fakeVaultsReader{t: t}, environmentTemplates: &fakeEnvironmentTemplates{t: t}, environmentTemplatesReader: &fakeEnvironmentTemplatesReader{t: t}, @@ -77,7 +78,7 @@ func testDependencies(t testing.TB) (Dependencies, *testFakes) { } return Dependencies{ Engine: "codex", CoreKeys: coreKeys(t, "admin"), InstallationBindings: f.installationBindings, - Projects: f.projects, + Projects: f.projects, ProjectsReader: f.projectsReader, ModelProviders: f.modelProviders, ModelProvidersReader: f.modelProvidersReader, Vaults: f.vaults, VaultsReader: f.vaultsReader, Files: f.files, FilesReader: f.filesReader, diff --git a/services/core/internal/api/environment_creation_test.go b/services/core/internal/api/environment_creation_test.go index c25f654c..3a00b9ce 100644 --- a/services/core/internal/api/environment_creation_test.go +++ b/services/core/internal/api/environment_creation_test.go @@ -58,10 +58,10 @@ func environmentCreationHandler(t *testing.T, engine string, configure ...func(* fixture := &environmentCreationFixture{} deps, fakes := testDependencies(t) deps.Engine = engine - fakes.projects.resolveProjectAPIKey = projectKeys(t, APIKey{ + fakes.projectsReader.resolveAPIKey = projectKeys(t, APIKey{ OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential("key"), TenantID: uuid.NewString(), - }).ResolveProjectAPIKey + }).ResolveAPIKey fixture.serve(fakes) fakes.sessions.findSessionCreation, fakes.sessions.createSession, fakes.sessions.createSessionStream = fixture.FindSessionCreation, fixture.CreateSession, fixture.CreateSessionStream fakes.modelProviders.resolve = fixtureDeploymentProvider diff --git a/services/core/internal/api/environment_executor_management_test.go b/services/core/internal/api/environment_executor_management_test.go index 48c5dff2..306fb3ea 100644 --- a/services/core/internal/api/environment_executor_management_test.go +++ b/services/core/internal/api/environment_executor_management_test.go @@ -11,6 +11,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -119,7 +120,7 @@ func TestProjectExecutorCredentialsHTTP(t *testing.T) { if w := projectKeyHTTP(h, "POST", path, "admin", body); w.Code != 409 || !strings.Contains(w.Body.String(), `"code":"executor_credential_exists"`) || strings.Contains(w.Body.String(), "synthetic-connect-only") { t.Fatal("uncertain retry", w.Code, w.Body) } - f.err = store.ErrProjectArchived + f.err = projects.ErrArchived if w := projectKeyHTTP(h, "POST", path, "admin", body); w.Code != 409 || !strings.Contains(w.Body.String(), `"code":"project_archived"`) { t.Fatal("archived Project", w.Code, w.Body) } diff --git a/services/core/internal/api/environment_files_create_test.go b/services/core/internal/api/environment_files_create_test.go index ea54aa4f..d7f14b6c 100644 --- a/services/core/internal/api/environment_files_create_test.go +++ b/services/core/internal/api/environment_files_create_test.go @@ -43,10 +43,10 @@ func environmentFileCreateHandler(t *testing.T, configure ...func(*Dependencies, base.environment.Configuration = json.RawMessage(`{"type":"openai_hosted","network":{"access":"disabled"}}`) f := &environmentFileCreateFixture{environmentFilesFixture: base} deps, fakes := testDependencies(t) - fakes.projects.resolveProjectAPIKey = projectKeys(t, + fakes.projectsReader.resolveAPIKey = projectKeys(t, APIKey{OrganizationID: "org", ProjectID: "project", SubjectKind: "user", SubjectID: "caller", TokenSHA256: runtimedevice.HashCredential("files-key"), TenantID: f.environment.TenantID}, APIKey{OrganizationID: "org", ProjectID: "other", SubjectKind: "user", SubjectID: "other", TokenSHA256: runtimedevice.HashCredential("other-key"), TenantID: uuid.NewString()}, - ).ResolveProjectAPIKey + ).ResolveAPIKey fakes.environments.getEnvironment = f.GetEnvironment deps.Execution = fakes.execution() fakes.workspaces.readEnvironmentDirectory, fakes.workspaces.writeEnvironmentFile = f.ReadEnvironmentDirectory, f.WriteEnvironmentFile diff --git a/services/core/internal/api/environment_files_test.go b/services/core/internal/api/environment_files_test.go index 82e8a94a..f54e27c9 100644 --- a/services/core/internal/api/environment_files_test.go +++ b/services/core/internal/api/environment_files_test.go @@ -78,7 +78,7 @@ func environmentFilesHandler(t *testing.T, enabled bool, configure ...func(*Depe } deps, fakes := testDependencies(t) deps.Engine = "fake_alpha" - fakes.projects.resolveProjectAPIKey = projectKeys(t, keys...).ResolveProjectAPIKey + fakes.projectsReader.resolveAPIKey = projectKeys(t, keys...).ResolveAPIKey fakes.environments.getEnvironment = f.GetEnvironment if enabled { deps.Execution = fakes.execution() diff --git a/services/core/internal/api/environment_installation_test.go b/services/core/internal/api/environment_installation_test.go index afcebc53..d94e18c0 100644 --- a/services/core/internal/api/environment_installation_test.go +++ b/services/core/internal/api/environment_installation_test.go @@ -35,7 +35,7 @@ func (f *installationFixture) ValidateEnvironmentInstallation(context.Context, s func TestSelfHostedCreationReturnsInstallationWithoutWebCredential(t *testing.T) { f := &installationFixture{} deps, fakes := testDependencies(t) - fakes.projects.resolveProjectAPIKey = projectKeys(t, APIKey{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential("project-key"), TenantID: uuid.NewString()}).ResolveProjectAPIKey + fakes.projectsReader.resolveAPIKey = projectKeys(t, APIKey{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential("project-key"), TenantID: uuid.NewString()}).ResolveAPIKey fakes.sessions.findSessionCreation, fakes.sessions.createSession = f.FindSessionCreation, f.CreateSession fakes.modelProviders.resolve = fixtureDeploymentProvider fakes.environments.authorizeEnvironmentInstallation, fakes.environments.validateEnvironmentInstallation = f.AuthorizeEnvironmentInstallation, f.ValidateEnvironmentInstallation diff --git a/services/core/internal/api/environments_test.go b/services/core/internal/api/environments_test.go index 6e908383..7ac92e3e 100644 --- a/services/core/internal/api/environments_test.go +++ b/services/core/internal/api/environments_test.go @@ -36,10 +36,10 @@ func environmentResourceHandler(t *testing.T) (http.Handler, *environmentResourc }} deps, fakes := testDependencies(t) deps.Engine = "fake_alpha" - fakes.projects.resolveProjectAPIKey = projectKeys(t, APIKey{ + fakes.projectsReader.resolveAPIKey = projectKeys(t, APIKey{ OrganizationID: "resource-org", ProjectID: "resource-project", SubjectKind: "user", SubjectID: "resource-reader", TokenSHA256: runtimedevice.HashCredential("resource-key"), TenantID: f.environment.TenantID, - }).ResolveProjectAPIKey + }).ResolveAPIKey fakes.environments.getEnvironment = f.GetEnvironment return newTestHandler(t, deps), f } diff --git a/services/core/internal/api/errors.go b/services/core/internal/api/errors.go index 259fcf01..83b7d2cf 100644 --- a/services/core/internal/api/errors.go +++ b/services/core/internal/api/errors.go @@ -11,6 +11,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" @@ -162,12 +163,10 @@ func writeStoreError(w http.ResponseWriter, r *http.Request, err error, notFound writeError(w, http.StatusConflict, "sandbox_node_address_mismatch", "This node uses a different Core address than the installation public URL. Generate a new command on the Nodes page and run it on the host.") case errors.Is(err, store.ErrRuntimeSpecificationMismatch): writeError(w, http.StatusConflict, "sandbox_specification_mismatch", "The node resource limits or Runtime release do not match the active deployment. Restore its installed configuration or remove and enroll the node again after a drained deployment change.") - case errors.Is(err, store.ErrProjectArchived): - writeError(w, http.StatusConflict, "project_archived", "The target Project is archived.") - case errors.Is(err, store.ErrProjectExists): - writeError(w, http.StatusConflict, "project_exists", "This Project ID already exists.") - case errors.Is(err, store.ErrProjectAPIKeyExists): - writeError(w, http.StatusConflict, "project_api_key_exists", "This API key ID already exists. List its metadata and revoke it explicitly if the secret was not saved.") + case errors.Is(err, projects.ErrArchived): + // Executor credential management checks the Project in its own + // transaction. + writeProjectsError(w, r, err) case errors.Is(err, store.ErrInstallationAuthorization): writeError(w, http.StatusUnauthorized, "installation_authorization_invalid", store.ErrInstallationAuthorization.Error()) case errors.Is(err, store.ErrExecutorCredentialExists): diff --git a/services/core/internal/api/errors_projects.go b/services/core/internal/api/errors_projects.go new file mode 100644 index 00000000..9882c604 --- /dev/null +++ b/services/core/internal/api/errors_projects.go @@ -0,0 +1,39 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" +) + +// writeProjectsError maps projects errors to the Core administration +// responses. +func writeProjectsError(w http.ResponseWriter, r *http.Request, err error) { + var name *projects.NameError + switch { + case errors.As(err, &name): + // The name limit is Core administration detail; other routes keep the + // generic response. + if !isCoreErrorWriter(w) { + writeError(w, http.StatusBadRequest, "invalid_request", invalidInputMessage) + return + } + writeCoreError(w, http.StatusBadRequest, "invalid_name", invalidNameMessage, CoreErrorDetails{"max_length": CoreErrorNumber(float64(name.MaxLength))}, "name") + case errors.Is(err, projects.ErrNotFound): + writeError(w, http.StatusNotFound, "not_found_error", "Resource not found.") + case errors.Is(err, projects.ErrInvalidInput): + writeError(w, http.StatusBadRequest, "invalid_request", invalidInputMessage) + case errors.Is(err, projects.ErrArchived): + writeError(w, http.StatusConflict, "project_archived", "The target Project is archived.") + case errors.Is(err, projects.ErrExists): + writeError(w, http.StatusConflict, "project_exists", "This Project ID already exists.") + case errors.Is(err, projects.ErrAPIKeyExists): + writeError(w, http.StatusConflict, "project_api_key_exists", "This API key ID already exists. List its metadata and revoke it explicitly if the secret was not saved.") + default: + if writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err) || writeCredentialUnavailableError(w, r, err) { + return + } + writeInternalError(w, r) + } +} diff --git a/services/core/internal/api/fakes_test.go b/services/core/internal/api/fakes_test.go index f673bf6f..52c83303 100644 --- a/services/core/internal/api/fakes_test.go +++ b/services/core/internal/api/fakes_test.go @@ -2,6 +2,7 @@ package api import ( "context" + "crypto/sha256" "encoding/json" "io" "testing" @@ -15,6 +16,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/files" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" @@ -591,79 +593,83 @@ func (f *fakeModelProvidersReader) List(a0 context.Context) ([]modelconfiguratio } type fakeProjects struct { - t testing.TB - createProject func(context.Context, string, string) (store.Project, error) - getProject func(context.Context, string) (store.ProjectBinding, error) - listProjects func(context.Context, string, int, bool) (store.ProjectPage, error) - renameProject func(context.Context, string, string) (store.Project, error) - archiveProject func(context.Context, string) (store.Project, error) - createProjectAPIKey func(context.Context, string, string, string) (store.IssuedProjectAPIKey, error) - listProjectAPIKeys func(context.Context, string, string, int, bool) (store.ProjectAPIKeyPage, error) - revokeProjectAPIKey func(context.Context, string, string) error - resolveProjectAPIKey func(context.Context, string) (store.ProjectAPIKeyBinding, error) -} - -func (f *fakeProjects) CreateProject(a0 context.Context, a1 string, a2 string) (store.Project, error) { + t testing.TB + createProject func(context.Context, projects.CreateProject) (projects.Project, error) + renameProject func(context.Context, projects.RenameProject) (projects.Project, error) + archiveProject func(context.Context, projects.ArchiveProject) (projects.Project, error) + createAPIKey func(context.Context, projects.CreateAPIKey) (projects.IssuedAPIKey, error) + revokeAPIKey func(context.Context, projects.RevokeAPIKey) error +} + +func (f *fakeProjects) CreateProject(a0 context.Context, a1 projects.CreateProject) (projects.Project, error) { if f.createProject == nil { unexpectedCall(f.t, "CreateProject") } - return f.createProject(a0, a1, a2) + return f.createProject(a0, a1) } -func (f *fakeProjects) GetProject(a0 context.Context, a1 string) (store.ProjectBinding, error) { - if f.getProject == nil { - unexpectedCall(f.t, "GetProject") +func (f *fakeProjects) RenameProject(a0 context.Context, a1 projects.RenameProject) (projects.Project, error) { + if f.renameProject == nil { + unexpectedCall(f.t, "RenameProject") } - return f.getProject(a0, a1) + return f.renameProject(a0, a1) } -func (f *fakeProjects) ListProjects(a0 context.Context, a1 string, a2 int, a3 bool) (store.ProjectPage, error) { - if f.listProjects == nil { - unexpectedCall(f.t, "ListProjects") +func (f *fakeProjects) ArchiveProject(a0 context.Context, a1 projects.ArchiveProject) (projects.Project, error) { + if f.archiveProject == nil { + unexpectedCall(f.t, "ArchiveProject") } - return f.listProjects(a0, a1, a2, a3) + return f.archiveProject(a0, a1) } -func (f *fakeProjects) RenameProject(a0 context.Context, a1 string, a2 string) (store.Project, error) { - if f.renameProject == nil { - unexpectedCall(f.t, "RenameProject") +func (f *fakeProjects) CreateAPIKey(a0 context.Context, a1 projects.CreateAPIKey) (projects.IssuedAPIKey, error) { + if f.createAPIKey == nil { + unexpectedCall(f.t, "CreateAPIKey") } - return f.renameProject(a0, a1, a2) + return f.createAPIKey(a0, a1) } -func (f *fakeProjects) ArchiveProject(a0 context.Context, a1 string) (store.Project, error) { - if f.archiveProject == nil { - unexpectedCall(f.t, "ArchiveProject") +func (f *fakeProjects) RevokeAPIKey(a0 context.Context, a1 projects.RevokeAPIKey) error { + if f.revokeAPIKey == nil { + unexpectedCall(f.t, "RevokeAPIKey") } - return f.archiveProject(a0, a1) + return f.revokeAPIKey(a0, a1) +} + +type fakeProjectsReader struct { + t testing.TB + getProject func(context.Context, string) (projects.Binding, error) + listProjects func(context.Context, projects.ListQuery) (projects.Page, error) + listAPIKeys func(context.Context, string, projects.ListQuery) (projects.KeyPage, error) + resolveAPIKey func(context.Context, [sha256.Size]byte) (projects.KeyBinding, error) } -func (f *fakeProjects) CreateProjectAPIKey(a0 context.Context, a1 string, a2 string, a3 string) (store.IssuedProjectAPIKey, error) { - if f.createProjectAPIKey == nil { - unexpectedCall(f.t, "CreateProjectAPIKey") +func (f *fakeProjectsReader) GetProject(a0 context.Context, a1 string) (projects.Binding, error) { + if f.getProject == nil { + unexpectedCall(f.t, "GetProject") } - return f.createProjectAPIKey(a0, a1, a2, a3) + return f.getProject(a0, a1) } -func (f *fakeProjects) ListProjectAPIKeys(a0 context.Context, a1 string, a2 string, a3 int, a4 bool) (store.ProjectAPIKeyPage, error) { - if f.listProjectAPIKeys == nil { - unexpectedCall(f.t, "ListProjectAPIKeys") +func (f *fakeProjectsReader) ListProjects(a0 context.Context, a1 projects.ListQuery) (projects.Page, error) { + if f.listProjects == nil { + unexpectedCall(f.t, "ListProjects") } - return f.listProjectAPIKeys(a0, a1, a2, a3, a4) + return f.listProjects(a0, a1) } -func (f *fakeProjects) RevokeProjectAPIKey(a0 context.Context, a1 string, a2 string) error { - if f.revokeProjectAPIKey == nil { - unexpectedCall(f.t, "RevokeProjectAPIKey") +func (f *fakeProjectsReader) ListAPIKeys(a0 context.Context, a1 string, a2 projects.ListQuery) (projects.KeyPage, error) { + if f.listAPIKeys == nil { + unexpectedCall(f.t, "ListAPIKeys") } - return f.revokeProjectAPIKey(a0, a1, a2) + return f.listAPIKeys(a0, a1, a2) } -func (f *fakeProjects) ResolveProjectAPIKey(a0 context.Context, a1 string) (store.ProjectAPIKeyBinding, error) { - if f.resolveProjectAPIKey == nil { - unexpectedCall(f.t, "ResolveProjectAPIKey") +func (f *fakeProjectsReader) ResolveAPIKey(a0 context.Context, a1 [sha256.Size]byte) (projects.KeyBinding, error) { + if f.resolveAPIKey == nil { + unexpectedCall(f.t, "ResolveAPIKey") } - return f.resolveProjectAPIKey(a0, a1) + return f.resolveAPIKey(a0, a1) } type fakeRuntimeHistory struct { diff --git a/services/core/internal/api/handler_test.go b/services/core/internal/api/handler_test.go index 6082f21b..325794d5 100644 --- a/services/core/internal/api/handler_test.go +++ b/services/core/internal/api/handler_test.go @@ -64,7 +64,7 @@ func testHandler(t *testing.T, configure ...func(*Dependencies, *testFakes)) (ht tenant := uuid.NewString() hash := sha256.Sum256([]byte("test-api-key")) deps, fakes := testDependencies(t) - fakes.projects.resolveProjectAPIKey = projectKeys(t, APIKey{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: hex.EncodeToString(hash[:]), TenantID: tenant}).ResolveProjectAPIKey + fakes.projectsReader.resolveAPIKey = projectKeys(t, APIKey{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: hex.EncodeToString(hash[:]), TenantID: tenant}).ResolveAPIKey s := &recordingStore{} s.record(fakes) fakes.modelProviders.resolve = noDeploymentModelProvider diff --git a/services/core/internal/api/hosted_failure_test.go b/services/core/internal/api/hosted_failure_test.go index 02e47ea9..c5a1d0f7 100644 --- a/services/core/internal/api/hosted_failure_test.go +++ b/services/core/internal/api/hosted_failure_test.go @@ -109,7 +109,7 @@ func TestGetStreamEndsAfterHostedProvisioningFailure(t *testing.T) { f.session.TenantID = uuid.NewString() f.session.Environment.TenantID = f.session.TenantID deps, fakes := testDependencies(t) - fakes.projects.resolveProjectAPIKey = projectKeys(t, APIKey{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential("key"), TenantID: f.session.TenantID}).ResolveProjectAPIKey + fakes.projectsReader.resolveAPIKey = projectKeys(t, APIKey{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential("key"), TenantID: f.session.TenantID}).ResolveAPIKey f.serve(fakes) h := newTestHandler(t, deps) server := httptest.NewServer(h) diff --git a/services/core/internal/api/installation_test.go b/services/core/internal/api/installation_test.go index cc25fcc8..e9b18bb1 100644 --- a/services/core/internal/api/installation_test.go +++ b/services/core/internal/api/installation_test.go @@ -13,7 +13,7 @@ import ( func TestInstallationReadNeedsOnlyTheCoreKey(t *testing.T) { deps, fakes := testDependencies(t) - fakes.projects.resolveProjectAPIKey = projectKeys(t, callerBinding()).ResolveProjectAPIKey + fakes.projectsReader.resolveAPIKey = projectKeys(t, callerBinding()).ResolveAPIKey deps.CoreKeys = coreKeys(t, "administrator") public, id := "https://core.example", "5b7c0f3e-0000-4000-8000-000000000001" settings, err := ParseInstallationConfiguration([]byte(`{"path":"/home/alice/.oac/core/config.json","apply_command":"/home/alice/.oac/core/oac apply", diff --git a/services/core/internal/api/project_api_key_configuration.go b/services/core/internal/api/project_api_key_configuration.go index 110f16d8..19b88da7 100644 --- a/services/core/internal/api/project_api_key_configuration.go +++ b/services/core/internal/api/project_api_key_configuration.go @@ -2,22 +2,29 @@ package api import ( "context" - "encoding/hex" + "crypto/sha256" "errors" ) -type projectKeySeparationStore interface { - ValidateProjectKeySeparation(context.Context, []string) error +// projectKeyDigests reports whether any Project API key, revoked or not, has +// a secret digest. +type projectKeyDigests interface { + APIKeyDigestExists(context.Context, [sha256.Size]byte) (bool, error) } // ValidateCredentialSeparation rejects Core key collisions with persisted API keys. -func ValidateCredentialSeparation(ctx context.Context, admin *DeploymentAuthenticator, s projectKeySeparationStore) error { +func ValidateCredentialSeparation(ctx context.Context, admin *DeploymentAuthenticator, keys projectKeyDigests) error { if admin == nil { return errors.New("OAC_CORE_KEY_DIGESTS_FILE is required; Core needs the Core key digest") } - digests := make([]string, 0, len(admin.digests)) for digest := range admin.digests { - digests = append(digests, hex.EncodeToString(digest[:])) + exists, err := keys.APIKeyDigestExists(ctx, digest) + if err != nil { + return err + } + if exists { + return errors.New("the Core key overlaps a persisted project API key") + } } - return s.ValidateProjectKeySeparation(ctx, digests) + return nil } diff --git a/services/core/internal/api/project_api_keys.go b/services/core/internal/api/project_api_keys.go index c794d83a..bf36c6b4 100644 --- a/services/core/internal/api/project_api_keys.go +++ b/services/core/internal/api/project_api_keys.go @@ -2,29 +2,34 @@ package api import ( "context" + "crypto/sha256" "encoding/hex" "net/http" "strconv" "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) -// Projects manages Projects and their API keys, and resolves a Project API key -// digest to its current binding for authentication. +// Projects runs the Project and API key administration use cases. type Projects interface { - CreateProject(context.Context, string, string) (store.Project, error) - GetProject(context.Context, string) (store.ProjectBinding, error) - ListProjects(context.Context, string, int, bool) (store.ProjectPage, error) - RenameProject(context.Context, string, string) (store.Project, error) - ArchiveProject(context.Context, string) (store.Project, error) - CreateProjectAPIKey(context.Context, string, string, string) (store.IssuedProjectAPIKey, error) - ListProjectAPIKeys(context.Context, string, string, int, bool) (store.ProjectAPIKeyPage, error) - RevokeProjectAPIKey(context.Context, string, string) error - ResolveProjectAPIKey(context.Context, string) (store.ProjectAPIKeyBinding, error) + CreateProject(context.Context, projects.CreateProject) (projects.Project, error) + RenameProject(context.Context, projects.RenameProject) (projects.Project, error) + ArchiveProject(context.Context, projects.ArchiveProject) (projects.Project, error) + CreateAPIKey(context.Context, projects.CreateAPIKey) (projects.IssuedAPIKey, error) + RevokeAPIKey(context.Context, projects.RevokeAPIKey) error +} + +// ProjectsReader reads Projects and their API keys, and resolves a Project API +// key digest to its current binding for authentication. +type ProjectsReader interface { + GetProject(context.Context, string) (projects.Binding, error) + ListProjects(context.Context, projects.ListQuery) (projects.Page, error) + ListAPIKeys(context.Context, string, projects.ListQuery) (projects.KeyPage, error) + ResolveAPIKey(context.Context, [sha256.Size]byte) (projects.KeyBinding, error) } type ProjectRequest struct { Name string `json:"name"` @@ -42,11 +47,11 @@ func (h *Handler) registerProjectAPIKeyRoutes(r chi.Router) { r.Post("/projects/{project_id}/keys", h.createProjectAPIKey) r.Delete("/projects/{project_id}/keys/{key_id}", h.revokeProjectAPIKey) } -func (h *Handler) adminProjectScope(w http.ResponseWriter, r *http.Request) (store.ProjectBinding, bool) { - p, err := h.Projects.GetProject(r.Context(), chi.URLParam(r, "project_id")) +func (h *Handler) adminProjectScope(w http.ResponseWriter, r *http.Request) (projects.Binding, bool) { + p, err := h.ProjectsReader.GetProject(r.Context(), chi.URLParam(r, "project_id")) if err != nil { - writeStoreError(w, r, err) - return store.ProjectBinding{}, false + writeProjectsError(w, r, err) + return projects.Binding{}, false } setAdminAuditSource(r, p.Project.ID) return p, true @@ -60,29 +65,28 @@ func setAdminAuditSource(r *http.Request, projectID string) { } *r = *r.WithContext(adminaudit.WithSource(r.Context(), source)) } -func adminCatalogPage(r *http.Request) (string, int, bool, error) { +func adminCatalogPage(r *http.Request) (projects.ListQuery, error) { values := r.URL.Query() - limit := 20 - ascending := false + query := projects.ListQuery{After: values.Get("after"), Limit: 20} for _, name := range []string{"after", "limit", "order"} { if len(values[name]) > 1 { - return "", 0, false, store.ErrInvalidInput + return query, projects.ErrInvalidInput } } if raw, ok := values["limit"]; ok { n, err := strconv.Atoi(raw[0]) - if err != nil || n < 1 || n > 100 { - return "", 0, false, store.ErrInvalidInput + if err != nil { + return query, projects.ErrInvalidInput } - limit = n + query.Limit = n } if raw, ok := values["order"]; ok { if raw[0] != "asc" && raw[0] != "desc" { - return "", 0, false, store.ErrInvalidInput + return query, projects.ErrInvalidInput } - ascending = raw[0] == "asc" + query.Ascending = raw[0] == "asc" } - return values.Get("after"), limit, ascending, nil + return query, query.Validate() } // @Summary List Projects and active key counts @@ -92,18 +96,18 @@ func adminCatalogPage(r *http.Request) (string, int, bool, error) { // @Param after query string false "Project ID cursor" // @Param limit query int false "Page size (1-100)" // @Param order query string false "asc or desc by Project ID" -// @Success 200 {object} store.ProjectPage +// @Success 200 {object} projects.Page // @Failure 400,401,404,500 {object} CoreErrorResponse // @Router /core/v1/projects [get] func (h *Handler) listProjects(w http.ResponseWriter, r *http.Request) { - after, limit, ascending, err := adminCatalogPage(r) + query, err := adminCatalogPage(r) if err != nil { - writeStoreError(w, r, err) + writeProjectsError(w, r, err) return } - page, err := h.Projects.ListProjects(r.Context(), after, limit, ascending) + page, err := h.ProjectsReader.ListProjects(r.Context(), query) if err != nil { - writeStoreError(w, r, err) + writeProjectsError(w, r, err) return } writeJSON(w, 200, page) @@ -115,7 +119,7 @@ func (h *Handler) listProjects(w http.ResponseWriter, r *http.Request) { // @Produce json // @Security DeploymentAdminAuth // @Param body body api.ProjectRequest true "Project display name" -// @Success 201 {object} store.Project +// @Success 201 {object} projects.Project // @Failure 400,401,409,500 {object} CoreErrorResponse // @Router /core/v1/projects [post] func (h *Handler) createProject(w http.ResponseWriter, r *http.Request) { @@ -125,14 +129,14 @@ func (h *Handler) createProject(w http.ResponseWriter, r *http.Request) { } var input ProjectRequest if decodeInputObject(raw, &input, "name") != nil { - writeStoreError(w, r, store.ErrInvalidInput) + writeError(w, http.StatusBadRequest, "invalid_request", invalidInputMessage) return } id := uuid.NewString() setAdminAuditSource(r, id) - p, err := h.Projects.CreateProject(r.Context(), id, input.Name) + p, err := h.Projects.CreateProject(r.Context(), projects.CreateProject{ID: id, Name: input.Name}) if err != nil { - writeStoreError(w, r, err) + writeProjectsError(w, r, err) return } writeJSON(w, 201, p) @@ -145,7 +149,7 @@ func (h *Handler) createProject(w http.ResponseWriter, r *http.Request) { // @Security DeploymentAdminAuth // @Param project_id path string true "Project UUID" // @Param body body api.ProjectRequest true "Project display name" -// @Success 200 {object} store.Project +// @Success 200 {object} projects.Project // @Failure 400,401,404,409,500 {object} CoreErrorResponse // @Router /core/v1/projects/{project_id} [post] func (h *Handler) renameProject(w http.ResponseWriter, r *http.Request) { @@ -159,12 +163,12 @@ func (h *Handler) renameProject(w http.ResponseWriter, r *http.Request) { } var input ProjectRequest if decodeInputObject(raw, &input, "name") != nil { - writeStoreError(w, r, store.ErrInvalidInput) + writeError(w, http.StatusBadRequest, "invalid_request", invalidInputMessage) return } - p, err := h.Projects.RenameProject(r.Context(), binding.Project.ID, input.Name) + p, err := h.Projects.RenameProject(r.Context(), projects.RenameProject{ID: binding.Project.ID, Name: input.Name}) if err != nil { - writeStoreError(w, r, err) + writeProjectsError(w, r, err) return } writeJSON(w, 200, p) @@ -175,7 +179,7 @@ func (h *Handler) renameProject(w http.ResponseWriter, r *http.Request) { // @Produce json // @Security DeploymentAdminAuth // @Param project_id path string true "Project UUID" -// @Success 200 {object} store.Project +// @Success 200 {object} projects.Project // @Failure 401,404,409,500 {object} CoreErrorResponse // @Router /core/v1/projects/{project_id}/archive [post] func (h *Handler) archiveProject(w http.ResponseWriter, r *http.Request) { @@ -183,9 +187,9 @@ func (h *Handler) archiveProject(w http.ResponseWriter, r *http.Request) { if !ok { return } - p, err := h.Projects.ArchiveProject(r.Context(), binding.Project.ID) + p, err := h.Projects.ArchiveProject(r.Context(), projects.ArchiveProject{ID: binding.Project.ID}) if err != nil { - writeStoreError(w, r, err) + writeProjectsError(w, r, err) return } writeJSON(w, 200, p) @@ -199,7 +203,7 @@ func (h *Handler) archiveProject(w http.ResponseWriter, r *http.Request) { // @Param after query string false "Key ID cursor" // @Param limit query int false "Page size (1-100)" // @Param order query string false "asc or desc by key ID" -// @Success 200 {object} store.ProjectAPIKeyPage +// @Success 200 {object} projects.KeyPage // @Failure 400,401,404,500 {object} CoreErrorResponse // @Router /core/v1/projects/{project_id}/keys [get] func (h *Handler) listProjectAPIKeys(w http.ResponseWriter, r *http.Request) { @@ -207,14 +211,14 @@ func (h *Handler) listProjectAPIKeys(w http.ResponseWriter, r *http.Request) { if !ok { return } - after, limit, ascending, err := adminCatalogPage(r) + query, err := adminCatalogPage(r) if err != nil { - writeStoreError(w, r, err) + writeProjectsError(w, r, err) return } - page, err := h.Projects.ListProjectAPIKeys(r.Context(), binding.Project.ID, after, limit, ascending) + page, err := h.ProjectsReader.ListAPIKeys(r.Context(), binding.Project.ID, query) if err != nil { - writeStoreError(w, r, err) + writeProjectsError(w, r, err) return } writeJSON(w, 200, page) @@ -227,7 +231,7 @@ func (h *Handler) listProjectAPIKeys(w http.ResponseWriter, r *http.Request) { // @Security DeploymentAdminAuth // @Param project_id path string true "Project UUID" // @Param body body api.ProjectAPIKeyRequest true "Key display name" -// @Success 201 {object} store.IssuedProjectAPIKey +// @Success 201 {object} projects.IssuedAPIKey // @Failure 400,401,404,409,500 {object} CoreErrorResponse // @Router /core/v1/projects/{project_id}/keys [post] func (h *Handler) createProjectAPIKey(w http.ResponseWriter, r *http.Request) { @@ -241,12 +245,12 @@ func (h *Handler) createProjectAPIKey(w http.ResponseWriter, r *http.Request) { } var input ProjectAPIKeyRequest if decodeInputObject(raw, &input, "name") != nil { - writeStoreError(w, r, store.ErrInvalidInput) + writeError(w, http.StatusBadRequest, "invalid_request", invalidInputMessage) return } - key, err := h.Projects.CreateProjectAPIKey(r.Context(), binding.Project.ID, uuid.NewString(), input.Name) + key, err := h.Projects.CreateAPIKey(r.Context(), projects.CreateAPIKey{ProjectID: binding.Project.ID, ID: uuid.NewString(), Name: input.Name}) if err != nil { - writeStoreError(w, r, err) + writeProjectsError(w, r, err) return } writeJSON(w, 201, key) @@ -267,8 +271,8 @@ func (h *Handler) revokeProjectAPIKey(w http.ResponseWriter, r *http.Request) { return } id := chi.URLParam(r, "key_id") - if err := h.Projects.RevokeProjectAPIKey(r.Context(), binding.Project.ID, id); err != nil { - writeStoreError(w, r, err) + if err := h.Projects.RevokeAPIKey(r.Context(), projects.RevokeAPIKey{ProjectID: binding.Project.ID, ID: id}); err != nil { + writeProjectsError(w, r, err) return } writeJSON(w, 200, SandboxMutationResponse{ID: id, Deleted: true}) diff --git a/services/core/internal/api/project_api_keys_test.go b/services/core/internal/api/project_api_keys_test.go index 2293954b..a7e2e42b 100644 --- a/services/core/internal/api/project_api_keys_test.go +++ b/services/core/internal/api/project_api_keys_test.go @@ -2,37 +2,38 @@ package api import ( "context" + "crypto/sha256" "errors" "net/http" "net/http/httptest" "strings" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type projectKeyStoreFixture struct { - binding store.ProjectAPIKeyBinding - project store.ProjectBinding + binding projects.KeyBinding + project projects.Binding resolve error lookups int } -func (s *projectKeyStoreFixture) GetProject(_ context.Context, id string) (store.ProjectBinding, error) { +func (s *projectKeyStoreFixture) GetProject(_ context.Context, id string) (projects.Binding, error) { if s.project.Project.ID == id { return s.project, nil } - return store.ProjectBinding{}, store.ErrNotFound + return projects.Binding{}, projects.ErrNotFound } -func (s *projectKeyStoreFixture) ResolveProjectAPIKey(_ context.Context, digest string) (store.ProjectAPIKeyBinding, error) { +func (s *projectKeyStoreFixture) ResolveAPIKey(_ context.Context, digest [sha256.Size]byte) (projects.KeyBinding, error) { s.lookups++ if s.resolve != nil { - return store.ProjectAPIKeyBinding{}, s.resolve + return projects.KeyBinding{}, s.resolve } - if digest != runtimedevice.HashCredential("issued-project-key") { - return store.ProjectAPIKeyBinding{}, store.ErrNotFound + if digest != sha256.Sum256([]byte("issued-project-key")) { + return projects.KeyBinding{}, projects.ErrNotFound } return s.binding, nil } @@ -48,7 +49,7 @@ func projectKeyHTTP(h http.Handler, method, path, token, body string) *httptest. func TestAdminCredentialNeverAuthenticatesPublicAPI(t *testing.T) { key := callerBinding() deps, fakes := testDependencies(t) - fakes.projects.resolveProjectAPIKey = projectKeys(t, key).ResolveProjectAPIKey + fakes.projectsReader.resolveAPIKey = projectKeys(t, key).ResolveAPIKey deps.CoreKeys = coreKeys(t, "caller") h := &Handler{Dependencies: deps} r := httptest.NewRequest("GET", "/v1/files", nil) @@ -60,10 +61,10 @@ func TestAdminCredentialNeverAuthenticatesPublicAPI(t *testing.T) { } func TestDatabaseResolverControlsAuthentication(t *testing.T) { p := callerBinding() - binding, _ := projectKeys(t, p).ResolveProjectAPIKey(t.Context(), p.TokenSHA256) + binding := projectKeyBinding(t, p) keys := &projectKeyStoreFixture{binding: binding} deps, fakes := testDependencies(t) - fakes.projects.resolveProjectAPIKey = keys.ResolveProjectAPIKey + fakes.projectsReader.resolveAPIKey = keys.ResolveAPIKey h := &Handler{Dependencies: deps} r := httptest.NewRequest("GET", "/v1/files", nil) r.Header.Set("Authorization", "Bearer issued-project-key") @@ -71,7 +72,7 @@ func TestDatabaseResolverControlsAuthentication(t *testing.T) { if err != nil || !ok || got != binding.Principal { t.Fatal("database key rejected") } - keys.resolve = store.ErrNotFound + keys.resolve = projects.ErrNotFound _, _, ok, err = h.resolveCaller(r) if err != nil || ok { t.Fatal("revoked database key authenticated") @@ -84,37 +85,41 @@ func TestDatabaseResolverControlsAuthentication(t *testing.T) { } type separationFixture struct { - digests []string + checked [][sha256.Size]byte + exists bool err error } -func (s *separationFixture) ValidateProjectKeySeparation(_ context.Context, digests []string) error { - s.digests = digests - return s.err +func (s *separationFixture) APIKeyDigestExists(_ context.Context, digest [sha256.Size]byte) (bool, error) { + s.checked = append(s.checked, digest) + return s.exists, s.err } func TestAdministratorCredentialSeparation(t *testing.T) { s := &separationFixture{} if err := ValidateCredentialSeparation(t.Context(), nil, s); err == nil { t.Fatal("missing administrator accepted") } - digest := runtimedevice.HashCredential("admin") - admin, _ := NewDeploymentAuthenticator([]string{digest}) - if err := ValidateCredentialSeparation(t.Context(), admin, s); err != nil || len(s.digests) != 1 || s.digests[0] != digest { + admin, _ := NewDeploymentAuthenticator([]string{runtimedevice.HashCredential("admin")}) + if err := ValidateCredentialSeparation(t.Context(), admin, s); err != nil || len(s.checked) != 1 || s.checked[0] != sha256.Sum256([]byte("admin")) { t.Fatal("administrator digest was not checked against persisted keys", err) } - s.err = errors.New("credential overlap") - if err := ValidateCredentialSeparation(t.Context(), admin, s); !errors.Is(err, s.err) { + s.exists = true + if err := ValidateCredentialSeparation(t.Context(), admin, s); err == nil { t.Fatal("persisted credential collision accepted") } + s.exists, s.err = false, errors.New("database unavailable") + if err := ValidateCredentialSeparation(t.Context(), admin, s); !errors.Is(err, s.err) { + t.Fatal("digest lookup failure ignored") + } } func TestAdminCatalogPageLimits(t *testing.T) { for _, query := range []string{"limit=101", "limit=0", "limit=bad", "limit=1&limit=2", "order=sideways", "order=asc&order=desc", "after=a&after=b"} { - if _, _, _, err := adminCatalogPage(httptest.NewRequest("GET", "/core/v1/projects?"+query, nil)); err == nil { + if _, err := adminCatalogPage(httptest.NewRequest("GET", "/core/v1/projects?"+query, nil)); err == nil { t.Errorf("invalid page accepted: %s", query) } } - _, limit, ascending, err := adminCatalogPage(httptest.NewRequest("GET", "/core/v1/projects?limit=100&order=asc", nil)) - if err != nil || limit != 100 || !ascending { + page, err := adminCatalogPage(httptest.NewRequest("GET", "/core/v1/projects?limit=100&order=asc", nil)) + if err != nil || page.Limit != 100 || !page.Ascending { t.Fatal("valid maximum page rejected", err) } } diff --git a/services/core/internal/api/resource_query_test.go b/services/core/internal/api/resource_query_test.go index bacc9684..864ee246 100644 --- a/services/core/internal/api/resource_query_test.go +++ b/services/core/internal/api/resource_query_test.go @@ -94,10 +94,10 @@ func twoTenantHandler(t *testing.T, configure ...func(*Dependencies, *testFakes) t.Helper() owner, foreign := uuid.NewString(), uuid.NewString() deps, fakes := testDependencies(t) - fakes.projects.resolveProjectAPIKey = projectKeys(t, + fakes.projectsReader.resolveAPIKey = projectKeys(t, APIKey{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "owner", TokenSHA256: runtimedevice.HashCredential("test-api-key"), TenantID: owner}, APIKey{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "foreign", TokenSHA256: runtimedevice.HashCredential("foreign-key"), TenantID: foreign}, - ).ResolveProjectAPIKey + ).ResolveAPIKey for _, c := range configure { c(&deps, fakes) } diff --git a/services/core/internal/api/routing_test.go b/services/core/internal/api/routing_test.go index f3047473..3478203f 100644 --- a/services/core/internal/api/routing_test.go +++ b/services/core/internal/api/routing_test.go @@ -3,6 +3,7 @@ package api import ( "bufio" "context" + "crypto/sha256" "encoding/json" "fmt" "io" @@ -86,11 +87,11 @@ func routingFixture(t *testing.T) (http.Handler, *chi.Mux, *routingStore) { t.Helper() tenant := uuid.NewString() keys := projectKeys(t, APIKey{OrganizationID: "test-org", ProjectID: "test-project", SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(routingKey), TenantID: tenant}) - keys[runtimedevice.HashCredential(routingDerivedKey)] = keys[runtimedevice.HashCredential(routingKey)] + keys[sha256.Sum256([]byte(routingDerivedKey))] = keys[sha256.Sum256([]byte(routingKey))] s := &routingStore{tenant: tenant, agent: agents.Agent{ID: uuid.NewString(), TenantID: tenant, Metadata: map[string]string{}, Configuration: json.RawMessage(`{"model":"fixture"}`), CreatedAt: time.Unix(1700000000, 0), UpdatedAt: time.Unix(1700000000, 0)}} deps, fakes := testDependencies(trapTB{t}) - fakes.projects.resolveProjectAPIKey = keys.ResolveProjectAPIKey + fakes.projectsReader.resolveAPIKey = keys.ResolveAPIKey fakes.agentsReader.getAgent, fakes.agentsReader.listAgents, fakes.agents.update = s.GetAgent, s.ListAgents, s.Update fakes.filesReader.get = func(context.Context, string, string) (files.File, error) { return files.File{}, files.ErrNotFound diff --git a/services/core/internal/api/sandbox_manager_test.go b/services/core/internal/api/sandbox_manager_test.go index a78e3737..b4322a3e 100644 --- a/services/core/internal/api/sandbox_manager_test.go +++ b/services/core/internal/api/sandbox_manager_test.go @@ -16,7 +16,7 @@ import ( func sandboxFakes(t testing.TB) (Dependencies, *testFakes) { t.Helper() deps, fakes := testDependencies(t) - fakes.projects.resolveProjectAPIKey = projectKeys(t, callerBinding()).ResolveProjectAPIKey + fakes.projectsReader.resolveAPIKey = projectKeys(t, callerBinding()).ResolveAPIKey deps.CoreKeys = coreKeys(t, "administrator") deps.Execution, deps.Sandboxes = fakes.execution(), fakes.sandboxes() return deps, fakes diff --git a/services/core/internal/api/session_creation_stream_test.go b/services/core/internal/api/session_creation_stream_test.go index 69dba858..ab64fc44 100644 --- a/services/core/internal/api/session_creation_stream_test.go +++ b/services/core/internal/api/session_creation_stream_test.go @@ -143,10 +143,10 @@ func newCreationStreamHarness(t *testing.T) *creationStreamHarness { Configuration: json.RawMessage(`{"agent":{"id":"agent_test","model":"model","tools":[]},"environment":{"type":"none"}}`), }}} deps, fakes := testDependencies(t) - fakes.projects.resolveProjectAPIKey = projectKeys(t, APIKey{ + fakes.projectsReader.resolveAPIKey = projectKeys(t, APIKey{ OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential("key"), TenantID: tenant, - }).ResolveProjectAPIKey + }).ResolveAPIKey fakes.sessions.getSession, fakes.sessions.findSessionCreation, fakes.sessions.auditSessionOperation = fixture.GetSession, fixture.FindSessionCreation, fixture.AuditSessionOperation fakes.sessionEvents.sessionEventCursor, fakes.sessionEvents.sessionStreamSnapshot, fakes.sessionEvents.listSessionEvents = fixture.SessionEventCursor, fixture.SessionStreamSnapshot, fixture.ListSessionEvents fakes.modelProviders.resolve = noDeploymentModelProvider diff --git a/services/core/internal/api/session_diagnostics_public_compat_test.go b/services/core/internal/api/session_diagnostics_public_compat_test.go index 25eea5fe..eef777ae 100644 --- a/services/core/internal/api/session_diagnostics_public_compat_test.go +++ b/services/core/internal/api/session_diagnostics_public_compat_test.go @@ -21,7 +21,7 @@ func TestDiagnosticPublicCompatibility(t *testing.T) { s, pool := diagnosticDatabase(t) key := callerBinding() deps, fakes := testDependencies(t) - fakes.projects.resolveProjectAPIKey = projectKeys(t, key).ResolveProjectAPIKey + fakes.projectsReader.resolveAPIKey = projectKeys(t, key).ResolveAPIKey databaseSessionReads(s)(&deps, fakes) h := newTestHandler(t, deps) session, err := s.CreateSession(t.Context(), key.TenantID, store.CreateSessionInput{Creator: identity.Subject{Kind: "service_account", ID: "compat-test"}, Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"id":"agent_root","model":"test"},"environment":{"type":"none"}}`)}) diff --git a/services/core/internal/api/session_environment_http_test.go b/services/core/internal/api/session_environment_http_test.go index 690f83ad..830de963 100644 --- a/services/core/internal/api/session_environment_http_test.go +++ b/services/core/internal/api/session_environment_http_test.go @@ -44,10 +44,10 @@ func TestSelfHostedSessionHTTPReadListMetadataAndLiveStream(t *testing.T) { EnvironmentInputActivity: session.EnvironmentInputActivity, }}}} deps, fakes := testDependencies(t) - fakes.projects.resolveProjectAPIKey = projectKeys(t, APIKey{ + fakes.projectsReader.resolveAPIKey = projectKeys(t, APIKey{ OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential("key"), TenantID: session.TenantID, - }).ResolveProjectAPIKey + }).ResolveAPIKey fixture.serve(fakes) fakes.sessions.listSessions, fakes.sessions.updateSessionMetadata = fixture.ListSessions, fixture.UpdateSessionMetadata // Self-hosted Sessions report the executor URL of the enabled Execution. diff --git a/services/core/internal/api/stream_authority_test.go b/services/core/internal/api/stream_authority_test.go index 8c76c697..b20327df 100644 --- a/services/core/internal/api/stream_authority_test.go +++ b/services/core/internal/api/stream_authority_test.go @@ -3,6 +3,7 @@ package api import ( "bufio" "context" + "crypto/sha256" "encoding/json" "errors" "net/http" @@ -12,6 +13,7 @@ import ( "time" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" @@ -24,12 +26,12 @@ type streamAuthorityResolver struct { calls atomic.Int32 } -func (r *streamAuthorityResolver) ResolveProjectAPIKey(ctx context.Context, digest string) (store.ProjectAPIKeyBinding, error) { +func (r *streamAuthorityResolver) ResolveAPIKey(ctx context.Context, digest [sha256.Size]byte) (projects.KeyBinding, error) { r.calls.Add(1) if r.unavailable.Load() { - return store.ProjectAPIKeyBinding{}, errors.New("resolver unavailable") + return projects.KeyBinding{}, errors.New("resolver unavailable") } - return r.keys.ResolveProjectAPIKey(ctx, digest) + return r.keys.ResolveAPIKey(ctx, digest) } type busyAuthorityStream struct { @@ -51,7 +53,7 @@ func TestBusyStreamRechecksAuthorityAndFailsClosed(t *testing.T) { resolver := &streamAuthorityResolver{keys: projectKeys(t, key)} f := &busyAuthorityStream{streamFixture: &streamFixture{session: store.Session{ID: uuid.NewString(), TenantID: key.TenantID, CreatedAt: time.Now(), Metadata: map[string]string{}, Configuration: json.RawMessage(`{"agent":{"id":"agent_fixture","model":"fixture","tools":[]},"environment":{"type":"none"}}`)}}} deps, fakes := testDependencies(t) - fakes.projects.resolveProjectAPIKey = resolver.ResolveProjectAPIKey + fakes.projectsReader.resolveAPIKey = resolver.ResolveAPIKey f.serve(fakes) fakes.sessionEvents.listSessionEvents = f.ListSessionEvents h := newTestHandler(t, deps) diff --git a/services/core/internal/api/stream_test.go b/services/core/internal/api/stream_test.go index 4437a2a3..d4f83ab8 100644 --- a/services/core/internal/api/stream_test.go +++ b/services/core/internal/api/stream_test.go @@ -70,7 +70,7 @@ func TestLiveStreamAuthDisconnectRecoveryAndServerDeadline(t *testing.T) { f := &streamFixture{session: store.Session{ID: uuid.NewString(), TenantID: uuid.NewString(), CreatedAt: time.Now(), Metadata: map[string]string{}, Configuration: json.RawMessage(`{"agent":{"id":"agent_test","model":"model","tools":[]},"environment":{"type":"none"}}`)}} deps, fakes := testDependencies(t) - fakes.projects.resolveProjectAPIKey = projectKeys(t, APIKey{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential("key"), TenantID: f.session.TenantID}, APIKey{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential("foreign"), TenantID: uuid.NewString()}).ResolveProjectAPIKey + fakes.projectsReader.resolveAPIKey = projectKeys(t, APIKey{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential("key"), TenantID: f.session.TenantID}, APIKey{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential("foreign"), TenantID: uuid.NewString()}).ResolveAPIKey f.serve(fakes) h := newTestHandler(t, deps) done := make(chan struct{}, 8) diff --git a/services/core/internal/api/vaults_test.go b/services/core/internal/api/vaults_test.go index e52faa0d..16350e19 100644 --- a/services/core/internal/api/vaults_test.go +++ b/services/core/internal/api/vaults_test.go @@ -48,7 +48,7 @@ func vaultResourceHandler(t *testing.T) (http.Handler, *vaultResourceFixture) { f := &vaultResourceFixture{vault: vaults.Vault{ID: uuid.NewString(), TenantID: uuid.NewString(), Metadata: map[string]string{}, CreatedAt: time.Unix(1700000000, 0)}} deps, fakes := testDependencies(t) deps.Engine = "fake_alpha" - fakes.projects.resolveProjectAPIKey = projectKeys(t, APIKey{OrganizationID: "vault-org", ProjectID: "vault-project", SubjectKind: "user", SubjectID: "vault-owner", TokenSHA256: runtimedevice.HashCredential("vault-key"), TenantID: f.vault.TenantID}).ResolveProjectAPIKey + fakes.projectsReader.resolveAPIKey = projectKeys(t, APIKey{OrganizationID: "vault-org", ProjectID: "vault-project", SubjectKind: "user", SubjectID: "vault-owner", TokenSHA256: runtimedevice.HashCredential("vault-key"), TenantID: f.vault.TenantID}).ResolveAPIKey fakes.vaults.createVault, fakes.vaults.deleteVault = f.CreateVault, f.DeleteVault fakes.vaultsReader.getVault, fakes.vaultsReader.listVaults = f.GetVault, f.ListVaults fakes.agents.update = f.UpdateAgent diff --git a/services/core/internal/api/write_audit_test.go b/services/core/internal/api/write_audit_test.go index 008c131c..41ccf5e2 100644 --- a/services/core/internal/api/write_audit_test.go +++ b/services/core/internal/api/write_audit_test.go @@ -11,7 +11,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" "github.com/google/uuid" ) @@ -45,9 +45,9 @@ func TestWriteAuditQueriesDeploymentScopeAndValidation(t *testing.T) { deps, fakes := testDependencies(t) queries := &auditQueryFixture{} fakes.writeAudit.getResourceOwners, fakes.writeAudit.listWriteOperations = queries.GetResourceOwners, queries.ListWriteOperations - projects := &projectKeyStoreFixture{project: store.ProjectBinding{Project: store.Project{ID: key.ProjectID}, Principal: identity.Principal{ProjectScope: identity.ProjectScope{TenantID: key.TenantID}}}} - fakes.projects.resolveProjectAPIKey = projectKeys(t, key).ResolveProjectAPIKey - fakes.projects.getProject = projects.GetProject + project := &projectKeyStoreFixture{project: projects.Binding{Project: projects.Project{ID: key.ProjectID}, Principal: identity.Principal{ProjectScope: identity.ProjectScope{TenantID: key.TenantID}}}} + fakes.projectsReader.resolveAPIKey = projectKeys(t, key).ResolveAPIKey + fakes.projectsReader.getProject = project.GetProject h := newTestHandler(t, deps) owners := "/core/v1/projects/" + key.ProjectID + "/resource-owners?resource_type=agent&resource_ids=first,second" for _, token := range []string{"", "caller", "foreign"} { @@ -93,11 +93,11 @@ func TestWriteAuditQueriesDeploymentScopeAndValidation(t *testing.T) { func TestAuthenticatedWriteProvenance(t *testing.T) { key := callerBinding() - binding, _ := projectKeys(t, key).ResolveProjectAPIKey(t.Context(), key.TokenSHA256) - binding.Key = store.ProjectAPIKey{ID: uuid.NewString(), Name: "SDK", Prefix: "pc_12345678"} + binding := projectKeyBinding(t, key) + binding.Key = projects.APIKey{ID: uuid.NewString(), Name: "SDK", Prefix: "pc_12345678"} keys := &projectKeyStoreFixture{binding: binding} deps, fakes := testDependencies(t) - fakes.projects.resolveProjectAPIKey = keys.ResolveProjectAPIKey + fakes.projectsReader.resolveAPIKey = keys.ResolveAPIKey h := &Handler{Dependencies: deps} var source writeaudit.Source var got bool @@ -122,7 +122,7 @@ func TestAuthenticatedWriteProvenance(t *testing.T) { t.Fatalf("source %+v", source) } } - keys.resolve = store.ErrNotFound + keys.resolve = projects.ErrNotFound if w := projectKeyHTTP(handler, "POST", "/v1/agents", "issued-project-key", ""); w.Code != 401 { t.Fatalf("revoked key %d", w.Code) } diff --git a/services/core/internal/execution/archive_cancellation_cleanup_test.go b/services/core/internal/execution/archive_cancellation_cleanup_test.go index 36cdde83..9b49367e 100644 --- a/services/core/internal/execution/archive_cancellation_cleanup_test.go +++ b/services/core/internal/execution/archive_cancellation_cleanup_test.go @@ -9,6 +9,9 @@ import ( "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/projectpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" @@ -63,7 +66,7 @@ func TestArchiveWaitingCleanupReceiptBarrier(t *testing.T) { }{{"Kill_no_delivery", false, false}, {"KillCompute_no_delivery", true, false}, {"Kill_live_delivery", false, true}, {"KillCompute_live_delivery", true, true}} { t.Run(scenario.name, func(t *testing.T) { checkpoint := scenario.checkpoint - s, leased := resetManagerStore(t) + s, leased, pool := resetManagerStoreDB(t, nil) writer := leased.Store installation := uuid.NewString() if err := writer.ClaimWebSandboxDeployment(t.Context(), installation); err != nil { @@ -77,7 +80,11 @@ func TestArchiveWaitingCleanupReceiptBarrier(t *testing.T) { } projectID := uuid.NewString() audit := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "fixture-admin", ProjectID: projectID, RequestID: uuid.NewString(), TraceID: uuid.NewString()}) - project, err := s.CreateProject(audit, projectID, "Cleanup diagnosis") + management, err := projects.NewService(projectpg.New(pgunit.NewPool(pool))) + if err != nil { + t.Fatal(err) + } + project, err := management.CreateProject(audit, projects.CreateProject{ID: projectID, Name: "Cleanup diagnosis"}) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/execution/sandbox_reset_test.go b/services/core/internal/execution/sandbox_reset_test.go index 1ebdca6b..cc533668 100644 --- a/services/core/internal/execution/sandbox_reset_test.go +++ b/services/core/internal/execution/sandbox_reset_test.go @@ -28,6 +28,14 @@ func resetManagerStore(t *testing.T) (*store.Store, Owner) { } func resetManagerStoreConfig(t *testing.T, configure func(*pgxpool.Config)) (*store.Store, Owner) { + t.Helper() + s, owner, _ := resetManagerStoreDB(t, configure) + return s, owner +} + +// resetManagerStoreDB also returns the test database, for tests that build +// adapters on it. +func resetManagerStoreDB(t *testing.T, configure func(*pgxpool.Config)) (*store.Store, Owner, *pgxpool.Pool) { t.Helper() pool := pgtest.OpenIsolated(t, configure) cipher, err := credentialcrypto.New(bytes.Repeat([]byte{8}, 32)) @@ -35,7 +43,7 @@ func resetManagerStoreConfig(t *testing.T, configure func(*pgxpool.Config)) (*st t.Fatal(err) } s := store.NewWithCredentialCipher(pool, cipher) - return s, testOwner(t, pool, s) + return s, testOwner(t, pool, s), pool } // testOwner acquires the execution lease on pool and builds s's execution diff --git a/services/core/internal/persistence/postgres/projectpg/projectpg.go b/services/core/internal/persistence/postgres/projectpg/projectpg.go new file mode 100644 index 00000000..b290b6f9 --- /dev/null +++ b/services/core/internal/persistence/postgres/projectpg/projectpg.go @@ -0,0 +1,286 @@ +// Package projectpg stores Projects and their API keys in PostgreSQL. It +// implements projects.Storage and projects.Reader. +package projectpg + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" +) + +type Store struct { + pool *pgunit.Pool +} + +var ( + _ projects.Storage = (*Store)(nil) + _ projects.Reader = (*Store)(nil) +) + +func New(pool *pgunit.Pool) *Store { return &Store{pool: pool} } + +func (s *Store) CreateProject(ctx context.Context, project projects.NewProject) (projects.Project, error) { + id, err := pgunit.ParseID(project.ID) + if err != nil { + return projects.Project{}, projects.ErrInvalidInput + } + tenant := pgtype.UUID{Bytes: uuid.New(), Valid: true} + var result projects.Project + err = s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + // The scope insert conflicts on the external Project ID, which the + // Project ID determines. + if _, err := q.EnsureProjectScope(ctx, sqlc.EnsureProjectScopeParams{TenantID: tenant, OrganizationID: project.OrganizationID, ProjectID: project.ExternalProjectID}); err != nil { + var db *pgconn.PgError + if errors.As(err, &db) && db.Code == "23505" { + return projects.ErrExists + } + return err + } + if _, err := q.CreateProject(ctx, sqlc.CreateProjectParams{ID: id, Name: project.Name, TenantID: tenant, SubjectID: project.SubjectID}); err != nil { + return err + } + row, err := q.GetProject(ctx, id) + if err != nil { + return err + } + result = binding(row).Project + return auditpg.RecordAdminMutation(ctx, q, result.TenantID, "create", "project", project.ID) + }) + if err != nil { + return projects.Project{}, err + } + return result, nil +} + +func (s *Store) RenameProject(ctx context.Context, id, name string) (projects.Project, error) { + return s.updateProject(ctx, id, "rename", func(ctx context.Context, q *sqlc.Queries, project pgtype.UUID) error { + return q.RenameProject(ctx, sqlc.RenameProjectParams{ID: project, Name: name}) + }) +} + +func (s *Store) ArchiveProject(ctx context.Context, id string) (projects.Project, error) { + return s.updateProject(ctx, id, "archive", func(ctx context.Context, q *sqlc.Queries, project pgtype.UUID) error { + if err := q.ArchiveProject(ctx, project); err != nil { + return err + } + return q.RevokeProjectKeys(ctx, project) + }) +} + +// updateProject applies one audited change under the Project's row lock. +func (s *Store) updateProject(ctx context.Context, id, action string, apply func(context.Context, *sqlc.Queries, pgtype.UUID) error) (projects.Project, error) { + project := pgunit.PathID(id) + var result projects.Project + err := s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + if _, err := q.LockProjectForUpdate(ctx, project); errors.Is(err, pgx.ErrNoRows) { + return projects.ErrNotFound + } else if err != nil { + return err + } + if err := apply(ctx, q, project); err != nil { + return err + } + row, err := q.GetProject(ctx, project) + if err != nil { + return err + } + result = binding(row).Project + return auditpg.RecordAdminMutation(ctx, q, result.TenantID, action, "project", id) + }) + if err != nil { + return projects.Project{}, err + } + return result, nil +} + +func (s *Store) WithKeyIssuance(ctx context.Context, projectID string, issue func(projects.KeyIssuanceTx) error) error { + project := pgunit.PathID(projectID) + return s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + row, err := q.LockProject(ctx, project) + if errors.Is(err, pgx.ErrNoRows) { + return projects.ErrNotFound + } + if err != nil { + return err + } + return issue(&keyIssuance{q: q, project: row}) + }) +} + +// keyIssuance holds the Project row that WithKeyIssuance locked. +type keyIssuance struct { + q *sqlc.Queries + project sqlc.Project +} + +func (k *keyIssuance) LoadProject(context.Context) (projects.LockedProject, error) { + return projects.LockedProject{Archived: k.project.ArchivedAt.Valid}, nil +} + +func (k *keyIssuance) ApplyAPIKey(ctx context.Context, key projects.NewAPIKey) (projects.APIKey, error) { + id, err := pgunit.ParseID(key.ID) + if err != nil { + return projects.APIKey{}, projects.ErrInvalidInput + } + row, err := k.q.CreateProjectAPIKey(ctx, sqlc.CreateProjectAPIKeyParams{ID: id, Name: key.Name, Prefix: key.Prefix, TokenSha256: hex.EncodeToString(key.Digest[:]), ProjectID: k.project.ID}) + if errors.Is(err, pgx.ErrNoRows) { + return projects.APIKey{}, projects.ErrAPIKeyExists + } + if err != nil { + return projects.APIKey{}, err + } + if err := auditpg.RecordAdminMutation(ctx, k.q, uuid.UUID(k.project.TenantID.Bytes).String(), "create", "api_key", key.ID); err != nil { + return projects.APIKey{}, err + } + return apiKey(row), nil +} + +func (s *Store) RevokeAPIKey(ctx context.Context, projectID, keyID string) error { + project := pgunit.PathID(projectID) + return s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + row, err := q.LockProject(ctx, project) + if errors.Is(err, pgx.ErrNoRows) { + return projects.ErrNotFound + } + if err != nil { + return err + } + if _, err := q.RevokeProjectAPIKey(ctx, sqlc.RevokeProjectAPIKeyParams{ID: pgunit.PathID(keyID), ProjectID: project}); errors.Is(err, pgx.ErrNoRows) { + return projects.ErrNotFound + } else if err != nil { + return err + } + return auditpg.RecordAdminMutation(ctx, q, uuid.UUID(row.TenantID.Bytes).String(), "revoke", "api_key", keyID) + }) +} + +func (s *Store) GetProject(ctx context.Context, id string) (projects.Binding, error) { + row, err := s.pool.Queries().GetProject(ctx, pgunit.PathID(id)) + if errors.Is(err, pgx.ErrNoRows) { + return projects.Binding{}, projects.ErrNotFound + } + if err != nil { + return projects.Binding{}, err + } + return binding(row), nil +} + +func (s *Store) ListProjects(ctx context.Context, query projects.ListQuery) (projects.Page, error) { + result := projects.Page{Data: []projects.Project{}} + if err := query.Validate(); err != nil { + return result, err + } + var rows []sqlc.ListProjectsRow + err := s.pool.Snapshot(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + if query.After != "" { + if _, err := q.GetProject(ctx, pgunit.PathID(query.After)); errors.Is(err, pgx.ErrNoRows) { + return projects.ErrNotFound + } else if err != nil { + return err + } + } + var err error + rows, err = q.ListProjects(ctx, sqlc.ListProjectsParams{AfterID: query.After, Ascending: query.Ascending, PageLimit: int32(query.Limit + 1)}) + return err + }) + if err != nil { + return result, err + } + result.HasMore = len(rows) > query.Limit + if result.HasMore { + rows = rows[:query.Limit] + } + for _, row := range rows { + result.Data = append(result.Data, binding(sqlc.GetProjectRow(row)).Project) + } + return result, nil +} + +func (s *Store) ListAPIKeys(ctx context.Context, projectID string, query projects.ListQuery) (projects.KeyPage, error) { + result := projects.KeyPage{Data: []projects.APIKey{}} + if err := query.Validate(); err != nil { + return result, err + } + project := pgunit.PathID(projectID) + var rows []sqlc.ProjectApiKey + err := s.pool.Snapshot(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + if _, err := q.GetProject(ctx, project); errors.Is(err, pgx.ErrNoRows) { + return projects.ErrNotFound + } else if err != nil { + return err + } + if query.After != "" { + if _, err := q.GetProjectAPIKeyForProject(ctx, sqlc.GetProjectAPIKeyForProjectParams{ID: pgunit.PathID(query.After), ProjectID: project}); errors.Is(err, pgx.ErrNoRows) { + return projects.ErrNotFound + } else if err != nil { + return err + } + } + var err error + rows, err = q.ListProjectAPIKeys(ctx, sqlc.ListProjectAPIKeysParams{ProjectID: project, AfterID: query.After, Ascending: query.Ascending, PageLimit: int32(query.Limit + 1)}) + return err + }) + if err != nil { + return result, err + } + result.HasMore = len(rows) > query.Limit + if result.HasMore { + rows = rows[:query.Limit] + } + for _, row := range rows { + result.Data = append(result.Data, apiKey(row)) + } + return result, nil +} + +func (s *Store) ResolveAPIKey(ctx context.Context, digest [sha256.Size]byte) (projects.KeyBinding, error) { + row, err := s.pool.Queries().ResolveProjectAPIKey(ctx, hex.EncodeToString(digest[:])) + if errors.Is(err, pgx.ErrNoRows) { + return projects.KeyBinding{}, projects.ErrNotFound + } + if err != nil { + return projects.KeyBinding{}, err + } + key := apiKey(sqlc.ProjectApiKey{ID: row.ID, ProjectID: row.ProjectID, Name: row.Name, Prefix: row.Prefix, CreatedAt: row.CreatedAt, RevokedAt: row.RevokedAt}) + return projects.KeyBinding{Key: key, Principal: identity.Principal{ProjectScope: identity.ProjectScope{TenantID: uuid.UUID(row.TenantID.Bytes).String(), OrganizationID: row.OrganizationID, ProjectID: row.ExternalProjectID}, SubjectKind: row.SubjectKind, SubjectID: row.SubjectID}}, nil +} + +func (s *Store) APIKeyDigestExists(ctx context.Context, digest [sha256.Size]byte) (bool, error) { + return s.pool.Queries().ProjectAPIKeyDigestExists(ctx, hex.EncodeToString(digest[:])) +} + +func binding(row sqlc.GetProjectRow) projects.Binding { + p := projects.Project{ID: uuid.UUID(row.ID.Bytes).String(), Name: row.Name, TenantID: uuid.UUID(row.TenantID.Bytes).String(), CreatedAt: row.CreatedAt.Time, ActiveKeyCount: row.ActiveKeyCount} + if row.ArchivedAt.Valid { + t := row.ArchivedAt.Time + p.ArchivedAt = &t + } + return projects.Binding{Project: p, Principal: identity.Principal{ProjectScope: identity.ProjectScope{TenantID: p.TenantID, OrganizationID: row.OrganizationID, ProjectID: row.ExternalProjectID}, SubjectKind: row.SubjectKind, SubjectID: row.SubjectID}} +} + +func apiKey(row sqlc.ProjectApiKey) projects.APIKey { + key := projects.APIKey{ID: uuid.UUID(row.ID.Bytes).String(), ProjectID: uuid.UUID(row.ProjectID.Bytes).String(), Name: row.Name, Prefix: row.Prefix, CreatedAt: row.CreatedAt.Time} + if row.RevokedAt.Valid { + t := row.RevokedAt.Time + key.RevokedAt = &t + } + return key +} diff --git a/services/core/internal/persistence/postgres/projectpg/projectpg_test.go b/services/core/internal/persistence/postgres/projectpg/projectpg_test.go new file mode 100644 index 00000000..4fd4c5d6 --- /dev/null +++ b/services/core/internal/persistence/postgres/projectpg/projectpg_test.go @@ -0,0 +1,455 @@ +package projectpg_test + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "reflect" + "strings" + "sync" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/projectpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" +) + +func newProjects(t *testing.T, pool *pgxpool.Pool) (*projects.Service, *projectpg.Store) { + t.Helper() + store := projectpg.New(pgunit.NewPool(pool)) + service, err := projects.NewService(store) + if err != nil { + t.Fatal(err) + } + return service, store +} + +func adminContext(ctx context.Context, projectID string) context.Context { + return adminaudit.WithSource(ctx, adminaudit.Source{CredentialID: "12345678", ActorLabel: "test", RequestID: uuid.NewString(), TraceID: uuid.NewString(), ProjectID: projectID}) +} + +func createProject(t *testing.T, service *projects.Service) projects.Project { + t.Helper() + id := uuid.NewString() + p, err := service.CreateProject(adminContext(t.Context(), id), projects.CreateProject{ID: id, Name: "Project"}) + if err != nil { + t.Fatal(err) + } + return p +} + +func createKey(t *testing.T, service *projects.Service, projectID, name string) projects.IssuedAPIKey { + t.Helper() + key, err := service.CreateAPIKey(adminContext(t.Context(), projectID), projects.CreateAPIKey{ProjectID: projectID, ID: uuid.NewString(), Name: name}) + if err != nil { + t.Fatal(err) + } + return key +} + +func digest(secret string) [sha256.Size]byte { return sha256.Sum256([]byte(secret)) } + +// insertAsset stands in for any tenant-owned asset; archiving must retain it. +func insertAsset(t *testing.T, pool *pgxpool.Pool, tenant string) string { + t.Helper() + id := uuid.NewString() + if _, err := pool.Exec(t.Context(), `INSERT INTO agents (id, tenant_id, metadata, configuration) VALUES ($1, $2, '{}', '{"model":"test"}')`, id, tenant); err != nil { + t.Fatal(err) + } + return id +} + +func assetTenant(t *testing.T, pool *pgxpool.Pool, id string) string { + t.Helper() + var tenant string + if err := pool.QueryRow(t.Context(), `SELECT tenant_id::text FROM agents WHERE id = $1`, id).Scan(&tenant); err != nil { + t.Fatal("asset missing", err) + } + return tenant +} + +func TestKeysShareTheProjectPrincipalAndArchiveRetainsAssets(t *testing.T) { + pool := pgtest.Open(t) + service, reader := newProjects(t, pool) + p := createProject(t, service) + other := createProject(t, service) + first := createKey(t, service, p.ID, "first") + second := createKey(t, service, p.ID, "second") + a, err := reader.ResolveAPIKey(t.Context(), digest(first.Key)) + if err != nil { + t.Fatal(err) + } + b, err := reader.ResolveAPIKey(t.Context(), digest(second.Key)) + if err != nil || a.Principal != b.Principal || a.Principal.SubjectID != "project:"+p.ID || a.Principal.ProjectID != "proj_"+p.ID || a.Principal.OrganizationID != "core" || a.Principal.SubjectKind != "service_account" { + t.Fatal("Project keys do not share the stable Project principal", err) + } + if a.Principal.TenantID != p.TenantID || other.TenantID == p.TenantID { + t.Fatal("Project tenants are not separate") + } + asset := insertAsset(t, pool, a.Principal.TenantID) + renamed, err := service.RenameProject(adminContext(t.Context(), p.ID), projects.RenameProject{ID: p.ID, Name: "renamed"}) + if err != nil || renamed.TenantID != p.TenantID || renamed.ActiveKeyCount != 2 || renamed.Name != "renamed" { + t.Fatal("rename changed Project ownership", err) + } + afterRename, err := reader.ResolveAPIKey(t.Context(), digest(first.Key)) + if err != nil || afterRename.Principal != a.Principal { + t.Fatal("rename changed key principal", err) + } + listed, err := reader.ListAPIKeys(t.Context(), p.ID, projects.ListQuery{Limit: 50, Ascending: true}) + if err != nil || len(listed.Data) != 2 { + t.Fatal("Project keys missing", err) + } + firstDigest := digest(first.Key) + raw, _ := json.Marshal(listed) + if strings.Contains(string(raw), first.Key) || strings.Contains(string(raw), hex.EncodeToString(firstDigest[:])) { + t.Fatal("key list exposed credential") + } + if err := service.RevokeAPIKey(adminContext(t.Context(), other.ID), projects.RevokeAPIKey{ProjectID: other.ID, ID: first.ID}); !errors.Is(err, projects.ErrNotFound) { + t.Fatal("foreign Project revoked key", err) + } + for range 2 { + if err := service.RevokeAPIKey(adminContext(t.Context(), p.ID), projects.RevokeAPIKey{ProjectID: p.ID, ID: first.ID}); err != nil { + t.Fatal("revocation is not idempotent", err) + } + } + if _, err := reader.ResolveAPIKey(t.Context(), digest(first.Key)); !errors.Is(err, projects.ErrNotFound) { + t.Fatal("revoked key authenticated") + } + if _, err := reader.ResolveAPIKey(t.Context(), digest(second.Key)); err != nil { + t.Fatal("revocation affected peer", err) + } + archived, err := service.ArchiveProject(adminContext(t.Context(), p.ID), projects.ArchiveProject{ID: p.ID}) + if err != nil || archived.ArchivedAt == nil || archived.ActiveKeyCount != 0 { + t.Fatal("archive did not revoke all keys", err) + } + if _, err := reader.ResolveAPIKey(t.Context(), digest(second.Key)); !errors.Is(err, projects.ErrNotFound) { + t.Fatal("archived Project authenticated") + } + if _, err := service.CreateAPIKey(adminContext(t.Context(), p.ID), projects.CreateAPIKey{ProjectID: p.ID, ID: uuid.NewString(), Name: "late"}); !errors.Is(err, projects.ErrArchived) { + t.Fatal("archived Project admitted new key", err) + } + if assetTenant(t, pool, asset) != p.TenantID { + t.Fatal("archive changed asset ownership") + } + // Credential separation covers revoked keys and archived Projects. + for _, secret := range []string{first.Key, second.Key} { + if exists, err := reader.APIKeyDigestExists(t.Context(), digest(secret)); err != nil || !exists { + t.Fatal("persisted digest not found", err) + } + } + if exists, err := reader.APIKeyDigestExists(t.Context(), digest(uuid.NewString())); err != nil || exists { + t.Fatal("unknown digest found", err) + } + raw, _ = json.Marshal(archived) + if strings.Contains(string(raw), p.TenantID) { + t.Fatal("Project response exposed internal tenant") + } +} + +func TestManagementRequiresAtomicAudit(t *testing.T) { + service, reader := newProjects(t, pgtest.Open(t)) + id := uuid.NewString() + if _, err := service.CreateProject(t.Context(), projects.CreateProject{ID: id, Name: "unaudited"}); !errors.Is(err, adminaudit.ErrInvalidSource) { + t.Fatal("unaudited Project accepted", err) + } + if _, err := reader.GetProject(t.Context(), id); !errors.Is(err, projects.ErrNotFound) { + t.Fatal("unaudited Project persisted") + } + p := createProject(t, service) + if _, err := service.CreateAPIKey(t.Context(), projects.CreateAPIKey{ProjectID: p.ID, ID: uuid.NewString(), Name: "unaudited"}); !errors.Is(err, adminaudit.ErrInvalidSource) { + t.Fatal("unaudited key accepted", err) + } + page, err := reader.ListAPIKeys(t.Context(), p.ID, projects.ListQuery{Limit: 20, Ascending: true}) + if err != nil || len(page.Data) != 0 { + t.Fatal("unaudited key persisted") + } + if _, err := service.RenameProject(t.Context(), projects.RenameProject{ID: p.ID, Name: "bad"}); !errors.Is(err, adminaudit.ErrInvalidSource) { + t.Fatal("unaudited rename accepted", err) + } + if _, err := service.ArchiveProject(t.Context(), projects.ArchiveProject{ID: p.ID}); !errors.Is(err, adminaudit.ErrInvalidSource) { + t.Fatal("unaudited archive accepted", err) + } + current, err := reader.GetProject(t.Context(), p.ID) + if err != nil || current.Project.Name != p.Name || current.Project.ArchivedAt != nil { + t.Fatal("unaudited mutation persisted") + } +} + +func TestMalformedIdentifiers(t *testing.T) { + service, reader := newProjects(t, pgtest.Open(t)) + if _, err := service.CreateProject(adminContext(t.Context(), uuid.NewString()), projects.CreateProject{ID: "not-a-uuid", Name: "Project"}); !errors.Is(err, projects.ErrInvalidInput) { + t.Fatal("malformed Project ID accepted", err) + } + p := createProject(t, service) + if _, err := service.CreateAPIKey(adminContext(t.Context(), p.ID), projects.CreateAPIKey{ProjectID: p.ID, ID: "not-a-uuid", Name: "key"}); !errors.Is(err, projects.ErrInvalidInput) { + t.Fatal("malformed key ID accepted", err) + } + for _, id := range []string{"not-a-uuid", uuid.Nil.String()} { + if _, err := reader.GetProject(t.Context(), id); !errors.Is(err, projects.ErrNotFound) { + t.Fatal("malformed Project ID found", err) + } + if _, err := reader.ListProjects(t.Context(), projects.ListQuery{After: id, Limit: 1}); !errors.Is(err, projects.ErrNotFound) { + t.Fatal("malformed Project cursor accepted", err) + } + if _, err := reader.ListAPIKeys(t.Context(), p.ID, projects.ListQuery{After: id, Limit: 1}); !errors.Is(err, projects.ErrNotFound) { + t.Fatal("malformed key cursor accepted", err) + } + if err := service.RevokeAPIKey(adminContext(t.Context(), p.ID), projects.RevokeAPIKey{ProjectID: p.ID, ID: id}); !errors.Is(err, projects.ErrNotFound) { + t.Fatal("malformed key revoked", err) + } + if _, err := service.ArchiveProject(adminContext(t.Context(), p.ID), projects.ArchiveProject{ID: id}); !errors.Is(err, projects.ErrNotFound) { + t.Fatal("malformed Project archived", err) + } + } +} + +func TestCatalogPaginationAndScopedKeyCursor(t *testing.T) { + service, reader := newProjects(t, pgtest.Open(t)) + first, second := createProject(t, service), createProject(t, service) + if _, err := service.CreateProject(adminContext(t.Context(), first.ID), projects.CreateProject{ID: first.ID, Name: "duplicate"}); !errors.Is(err, projects.ErrExists) { + t.Fatal("duplicate Project ID did not conflict", err) + } + // Equal display names do not merge Projects or keys. + a := createKey(t, service, first.ID, "same name") + b := createKey(t, service, first.ID, "same name") + if _, err := service.CreateAPIKey(adminContext(t.Context(), first.ID), projects.CreateAPIKey{ProjectID: first.ID, ID: a.ID, Name: "duplicate ID"}); !errors.Is(err, projects.ErrAPIKeyExists) { + t.Fatal("duplicate key ID did not conflict", err) + } + page, err := reader.ListAPIKeys(t.Context(), first.ID, projects.ListQuery{Limit: 1, Ascending: true}) + if err != nil || len(page.Data) != 1 || !page.HasMore { + t.Fatal("first key page invalid", err) + } + tail, err := reader.ListAPIKeys(t.Context(), first.ID, projects.ListQuery{After: page.Data[0].ID, Limit: 1, Ascending: true}) + if err != nil || len(tail.Data) != 1 || tail.HasMore || tail.Data[0].ID <= page.Data[0].ID { + t.Fatal("key cursor did not advance", err) + } + reverse, err := reader.ListAPIKeys(t.Context(), first.ID, projects.ListQuery{Limit: 1}) + if err != nil || len(reverse.Data) != 1 || reverse.Data[0].ID != tail.Data[0].ID { + t.Fatal("descending key page invalid", err) + } + if _, err := reader.ListAPIKeys(t.Context(), second.ID, projects.ListQuery{After: a.ID, Limit: 1, Ascending: true}); !errors.Is(err, projects.ErrNotFound) { + t.Fatal("foreign key cursor accepted", err) + } + if _, err := reader.ListAPIKeys(t.Context(), uuid.NewString(), projects.ListQuery{Limit: 1}); !errors.Is(err, projects.ErrNotFound) { + t.Fatal("missing Project listed keys", err) + } + if _, err := reader.ListAPIKeys(t.Context(), first.ID, projects.ListQuery{Limit: 101, Ascending: true}); !errors.Is(err, projects.ErrInvalidInput) { + t.Fatal("oversized key page accepted", err) + } + if _, err := reader.ListProjects(t.Context(), projects.ListQuery{Limit: 101, Ascending: true}); !errors.Is(err, projects.ErrInvalidInput) { + t.Fatal("oversized Project page accepted", err) + } + projectsPage, err := reader.ListProjects(t.Context(), projects.ListQuery{After: first.ID, Limit: 100, Ascending: first.ID < second.ID}) + if err != nil || !containsProject(projectsPage, second.ID) || containsProject(projectsPage, first.ID) { + t.Fatal("Project cursor did not advance past itself", err) + } + for _, id := range []string{b.ID, a.ID} { + if err := service.RevokeAPIKey(adminContext(t.Context(), first.ID), projects.RevokeAPIKey{ProjectID: first.ID, ID: id}); err != nil { + t.Fatal(err) + } + } + current, err := reader.GetProject(t.Context(), first.ID) + if err != nil || current.Project.ArchivedAt != nil || current.Project.ActiveKeyCount != 0 { + t.Fatal("last key removal changed Project lifecycle", err) + } + createKey(t, service, first.ID, "new access") +} + +func containsProject(page projects.Page, id string) bool { + for _, p := range page.Data { + if p.ID == id { + return true + } + } + return false +} + +// Key issuance holds a shared Project lock, so a key issued while the Project +// is being archived is either revoked by the archive or refused after it. +func TestArchiveRacesKeyIssuance(t *testing.T) { + pool := pgtest.Open(t) + service, reader := newProjects(t, pool) + p := createProject(t, service) + var wg sync.WaitGroup + issued := make(chan projects.IssuedAPIKey, 16) + for range 16 { + wg.Go(func() { + key, err := service.CreateAPIKey(adminContext(t.Context(), p.ID), projects.CreateAPIKey{ProjectID: p.ID, ID: uuid.NewString(), Name: "racing"}) + if err == nil { + issued <- key + } else if !errors.Is(err, projects.ErrArchived) { + t.Error(err) + } + }) + } + wg.Go(func() { + if _, err := service.ArchiveProject(adminContext(t.Context(), p.ID), projects.ArchiveProject{ID: p.ID}); err != nil { + t.Error(err) + } + }) + wg.Wait() + close(issued) + for key := range issued { + if _, err := reader.ResolveAPIKey(t.Context(), digest(key.Key)); !errors.Is(err, projects.ErrNotFound) { + t.Fatal("key issued during archive authenticates", err) + } + } + current, err := reader.GetProject(t.Context(), p.ID) + if err != nil || current.Project.ArchivedAt == nil || current.Project.ActiveKeyCount != 0 { + t.Fatal("archived Project retains an active key", err) + } +} + +const rejectedRequest = "reject-admin-mutation-fixture" + +// rejectAuditInsert makes the audit insert of one request fail. The sequence +// survives rollback, so it proves the mutation reached its audit insert. +func rejectAuditInsert(t *testing.T, pool *pgxpool.Pool) { + t.Helper() + _, err := pool.Exec(t.Context(), `CREATE SEQUENCE admin_mutation_rejections; + CREATE FUNCTION reject_admin_mutation_fixture() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN IF NEW.request_id = 'reject-admin-mutation-fixture' THEN PERFORM nextval('admin_mutation_rejections'); RAISE EXCEPTION 'forced administrator audit failure'; END IF; RETURN NEW; END $$; + CREATE TRIGGER reject_admin_mutation_fixture BEFORE INSERT ON admin_audit_log FOR EACH ROW EXECUTE FUNCTION reject_admin_mutation_fixture()`) + if err != nil { + t.Fatal(err) + } +} + +func auditRejections(t *testing.T, pool *pgxpool.Pool) int64 { + t.Helper() + var count int64 + if err := pool.QueryRow(t.Context(), "SELECT CASE WHEN is_called THEN last_value ELSE 0 END FROM admin_mutation_rejections").Scan(&count); err != nil { + t.Fatal(err) + } + return count +} + +// snapshot captures whole tables, so the test owns an isolated database. +func snapshot(t *testing.T, pool *pgxpool.Pool, tables ...string) map[string]string { + t.Helper() + result := make(map[string]string, len(tables)) + for _, table := range tables { + var rows string + if err := pool.QueryRow(t.Context(), "SELECT COALESCE(jsonb_agg(to_jsonb(r) ORDER BY to_jsonb(r)::text)::text,'[]') FROM "+pgx.Identifier{table}.Sanitize()+" r").Scan(&rows); err != nil { + t.Fatalf("snapshot %s: %v", table, err) + } + result[table] = rows + } + return result +} + +func TestFailedAuditRollsBackEveryMutation(t *testing.T) { + pool := pgtest.OpenIsolated(t, nil) + service, reader := newProjects(t, pool) + rejectAuditInsert(t, pool) + for _, action := range []string{"project_create", "rename", "archive", "key_create", "revoke"} { + t.Run(action, func(t *testing.T) { + projectID, keyID := uuid.NewString(), uuid.NewString() + var p projects.Project + var issued projects.IssuedAPIKey + var asset string + ctxFor := func(request string) context.Context { + return adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "87654321", ActorLabel: "administrator fixture", ProjectID: projectID, RequestID: request, TraceID: "admin-mutation-trace"}) + } + if action != "project_create" { + var err error + p, err = service.CreateProject(ctxFor(uuid.NewString()), projects.CreateProject{ID: projectID, Name: "original"}) + if err != nil { + t.Fatal(err) + } + asset = insertAsset(t, pool, p.TenantID) + if action == "archive" || action == "revoke" { + issued, err = service.CreateAPIKey(ctxFor(uuid.NewString()), projects.CreateAPIKey{ProjectID: projectID, ID: keyID, Name: "original key"}) + if err != nil { + t.Fatal(err) + } + } + } + mutate := func(request string) (projects.Project, projects.IssuedAPIKey, error) { + ctx := ctxFor(request) + switch action { + case "project_create": + v, e := service.CreateProject(ctx, projects.CreateProject{ID: projectID, Name: "created"}) + return v, projects.IssuedAPIKey{}, e + case "rename": + v, e := service.RenameProject(ctx, projects.RenameProject{ID: projectID, Name: "renamed"}) + return v, projects.IssuedAPIKey{}, e + case "archive": + v, e := service.ArchiveProject(ctx, projects.ArchiveProject{ID: projectID}) + return v, projects.IssuedAPIKey{}, e + case "key_create": + v, e := service.CreateAPIKey(ctx, projects.CreateAPIKey{ProjectID: projectID, ID: keyID, Name: "created key"}) + return projects.Project{}, v, e + default: + return projects.Project{}, projects.IssuedAPIKey{}, service.RevokeAPIKey(ctx, projects.RevokeAPIKey{ProjectID: projectID, ID: keyID}) + } + } + tables := []string{"execution_project_scopes", "projects", "project_api_keys", "agents", "admin_audit_log", "write_audit_operations", "write_audit_owners"} + before := snapshot(t, pool, tables...) + rejections := auditRejections(t, pool) + rejectedProject, rejectedKey, err := mutate(rejectedRequest) + if err == nil || auditRejections(t, pool) != rejections+1 { + t.Fatal("mutation did not reach the failing final audit insertion", err) + } + if rejectedProject.ID != "" || rejectedKey.Key != "" { + t.Fatal("failed transaction exposed an uncommitted resource") + } + if !reflect.DeepEqual(before, snapshot(t, pool, tables...)) { + t.Fatal("failed audit changed Project, key, asset, or audit state") + } + if issued.Key != "" { + if _, err := reader.ResolveAPIKey(t.Context(), digest(issued.Key)); err != nil { + t.Fatal("failed audit invalidated original key", err) + } + } + request := uuid.NewString() + result, key, err := mutate(request) + if err != nil { + t.Fatal(err) + } + if action == "project_create" { + p = result + } + expectedAction, kind, resource := action, "project", projectID + switch action { + case "project_create": + expectedAction = "create" + case "key_create": + expectedAction = "create" + kind = "api_key" + resource = keyID + case "revoke": + kind = "api_key" + resource = keyID + } + var count int + if err := pool.QueryRow(t.Context(), `SELECT count(*) FROM admin_audit_log WHERE project_id=$1 AND tenant_id=$2 AND request_id=$3 AND action=$4 AND resource_type=$5 AND resource_id=$6 AND admin_credential_id='87654321' AND actor_label='administrator fixture' AND trace_id='admin-mutation-trace'`, projectID, p.TenantID, request, expectedAction, kind, resource).Scan(&count); err != nil || count != 1 { + t.Fatal("successful mutation lacks matching audit", err) + } + if action == "key_create" { + if key.Key == "" { + t.Fatal("issuance lacks plaintext") + } + if _, err := reader.ResolveAPIKey(t.Context(), digest(key.Key)); err != nil { + t.Fatal(err) + } + } + if action == "archive" || action == "revoke" { + if _, err := reader.ResolveAPIKey(t.Context(), digest(issued.Key)); !errors.Is(err, projects.ErrNotFound) { + t.Fatal("invalidated key authenticated", err) + } + } + if action != "project_create" && assetTenant(t, pool, asset) != p.TenantID { + t.Fatal("management mutation changed assets") + } + }) + } +} diff --git a/services/core/internal/projects/api_key.go b/services/core/internal/projects/api_key.go new file mode 100644 index 00000000..05c64dc8 --- /dev/null +++ b/services/core/internal/projects/api_key.go @@ -0,0 +1,53 @@ +package projects + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" +) + +// APIKey is the safe metadata of a Project API key. Core stores only the +// secret's SHA-256 digest and its display prefix. +type APIKey struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + CreatedAt time.Time `json:"created_at"` + RevokedAt *time.Time `json:"revoked_at"` +} + +// IssuedAPIKey carries the secret once, in the response that issues it. +type IssuedAPIKey struct { + APIKey + Key string `json:"key"` +} + +// KeyBinding is an active key with the principal it authenticates as. +type KeyBinding struct { + Key APIKey + Principal identity.Principal +} + +// KeyPage is one page of a Project's keys ordered by ID. +type KeyPage struct { + Data []APIKey `json:"data"` + HasMore bool `json:"has_more"` +} + +// keyPrefixLength covers "pc_" and eight secret characters. +const keyPrefixLength = 11 + +// newSecret returns a new API key secret, "pc_" followed by 32 random bytes in +// unpadded base64url, and the SHA-256 digest that authenticates it. +func newSecret() (string, [sha256.Size]byte, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", [sha256.Size]byte{}, err + } + secret := "pc_" + base64.RawURLEncoding.EncodeToString(raw) + return secret, sha256.Sum256([]byte(secret)), nil +} diff --git a/services/core/internal/projects/doc.go b/services/core/internal/projects/doc.go new file mode 100644 index 00000000..755a57da --- /dev/null +++ b/services/core/internal/projects/doc.go @@ -0,0 +1,5 @@ +// Package projects owns Core Projects and their API keys. A Project is one +// tenant with its own execution scope and assets; its API keys authenticate the +// public API as that Project. Archiving a Project revokes its keys and keeps its +// assets. +package projects diff --git a/services/core/internal/projects/errors.go b/services/core/internal/projects/errors.go new file mode 100644 index 00000000..59d0186a --- /dev/null +++ b/services/core/internal/projects/errors.go @@ -0,0 +1,31 @@ +package projects + +import ( + "errors" + "fmt" +) + +var ( + // ErrNotFound reports a missing Project, key or list cursor, including a + // malformed identifier. + ErrNotFound = errors.New("project or API key not found") + // ErrInvalidInput reports a malformed identifier for a new Project or key, + // or a list page size outside 1..MaxListLimit. + ErrInvalidInput = errors.New("invalid project input") + // ErrArchived reports an operation that needs an active Project. + ErrArchived = errors.New("project is archived") + // ErrExists reports a Project ID that is already in use. + ErrExists = errors.New("project ID already exists") + // ErrAPIKeyExists reports an API key ID that is already in use. + ErrAPIKeyExists = errors.New("API key ID already exists") +) + +// NameError rejects a Project or key display name. +type NameError struct { + // MaxLength is the name's limit in Unicode code points. + MaxLength int +} + +func (e *NameError) Error() string { + return fmt.Sprintf("name must contain 1–%d characters without controls", e.MaxLength) +} diff --git a/services/core/internal/projects/names.go b/services/core/internal/projects/names.go new file mode 100644 index 00000000..8e70a72d --- /dev/null +++ b/services/core/internal/projects/names.go @@ -0,0 +1,26 @@ +package projects + +import ( + "strings" + "unicode" + "unicode/utf8" +) + +// Display name limits, in Unicode code points. They are separate from the +// sandbox node name limit, which counts bytes. +const ( + ProjectNameMaxLength = 128 + KeyNameMaxLength = 80 +) + +// normalizeName trims surrounding space from an administrator-supplied display +// name. The name must be valid UTF-8, nonempty after trimming, at most max code +// points, and free of control characters anywhere in the submitted value. +func normalizeName(value string, max int) (string, error) { + control := strings.ContainsFunc(value, unicode.IsControl) + value = strings.TrimSpace(value) + if value == "" || !utf8.ValidString(value) || utf8.RuneCountInString(value) > max || control { + return "", &NameError{MaxLength: max} + } + return value, nil +} diff --git a/services/core/internal/projects/names_test.go b/services/core/internal/projects/names_test.go new file mode 100644 index 00000000..292bcfec --- /dev/null +++ b/services/core/internal/projects/names_test.go @@ -0,0 +1,36 @@ +package projects + +import ( + "errors" + "fmt" + "strings" + "testing" +) + +func TestNameValidationIdentityAndBoundaries(t *testing.T) { + for _, max := range []int{KeyNameMaxLength, ProjectNameMaxLength} { + for _, name := range []string{"", " ", "private-name\t", strings.Repeat("a", max+1), string([]byte{0xff})} { + _, err := normalizeName(name, max) + var field *NameError + if !errors.As(err, &field) || field.MaxLength != max || err.Error() != fmt.Sprintf("name must contain 1–%d characters without controls", max) { + t.Fatalf("wrong name error: %#v", err) + } + } + if got, err := normalizeName(" "+strings.Repeat("界", max)+" ", max); err != nil || got != strings.Repeat("界", max) { + t.Fatal("rune/trim semantics changed", err) + } + } +} + +func TestListQueryPageSize(t *testing.T) { + for _, limit := range []int{-1, 0, MaxListLimit + 1} { + if err := (ListQuery{Limit: limit}).Validate(); !errors.Is(err, ErrInvalidInput) { + t.Fatal("page size accepted", limit, err) + } + } + for _, limit := range []int{1, MaxListLimit} { + if err := (ListQuery{Limit: limit}).Validate(); err != nil { + t.Fatal("page size rejected", limit, err) + } + } +} diff --git a/services/core/internal/projects/project.go b/services/core/internal/projects/project.go new file mode 100644 index 00000000..869fc389 --- /dev/null +++ b/services/core/internal/projects/project.go @@ -0,0 +1,55 @@ +package projects + +import ( + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" +) + +// Project is a Core Project as administrators see it. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + ArchivedAt *time.Time `json:"archived_at"` + ActiveKeyCount int64 `json:"active_key_count"` + TenantID string `json:"-"` +} + +// Binding is a Project with the principal its API keys authenticate as. +type Binding struct { + Project Project + Principal identity.Principal +} + +// Page is one page of Projects ordered by ID. +type Page struct { + Data []Project `json:"data"` + HasMore bool `json:"has_more"` +} + +// MaxListLimit bounds one page of Projects or API keys. +const MaxListLimit = 100 + +// ListQuery selects one page ordered by ID. After, when set, must name an +// existing entry of the list. +type ListQuery struct { + After string + Limit int + Ascending bool +} + +// Validate rejects a page size outside 1..MaxListLimit. +func (q ListQuery) Validate() error { + if q.Limit < 1 || q.Limit > MaxListLimit { + return ErrInvalidInput + } + return nil +} + +// The execution scope and subject every new Project's keys authenticate as. +const organizationID = "core" + +func newProject(id, name string) NewProject { + return NewProject{ID: id, Name: name, OrganizationID: organizationID, ExternalProjectID: "proj_" + id, SubjectID: "project:" + id} +} diff --git a/services/core/internal/projects/service.go b/services/core/internal/projects/service.go new file mode 100644 index 00000000..2da4b960 --- /dev/null +++ b/services/core/internal/projects/service.go @@ -0,0 +1,101 @@ +package projects + +import ( + "context" + "errors" +) + +// Service runs the Project and key administration use cases. Reads go to the +// Reader directly. +type Service struct { + storage Storage +} + +func NewService(storage Storage) (*Service, error) { + if storage == nil { + return nil, errors.New("projects: storage is required") + } + return &Service{storage: storage}, nil +} + +// CreateProject creates an empty Project. The caller chooses its ID. +type CreateProject struct { + ID, Name string +} + +func (s *Service) CreateProject(ctx context.Context, command CreateProject) (Project, error) { + name, err := normalizeName(command.Name, ProjectNameMaxLength) + if err != nil { + return Project{}, err + } + return s.storage.CreateProject(ctx, newProject(command.ID, name)) +} + +// RenameProject changes a Project's display name, archived or not. +type RenameProject struct { + ID, Name string +} + +func (s *Service) RenameProject(ctx context.Context, command RenameProject) (Project, error) { + name, err := normalizeName(command.Name, ProjectNameMaxLength) + if err != nil { + return Project{}, err + } + return s.storage.RenameProject(ctx, command.ID, name) +} + +// ArchiveProject archives a Project and revokes all of its keys. Its assets +// remain. +type ArchiveProject struct { + ID string +} + +func (s *Service) ArchiveProject(ctx context.Context, command ArchiveProject) (Project, error) { + return s.storage.ArchiveProject(ctx, command.ID) +} + +// CreateAPIKey issues a new secret in an active Project. The caller chooses +// the key ID. +type CreateAPIKey struct { + ProjectID, ID, Name string +} + +func (s *Service) CreateAPIKey(ctx context.Context, command CreateAPIKey) (IssuedAPIKey, error) { + name, err := normalizeName(command.Name, KeyNameMaxLength) + if err != nil { + return IssuedAPIKey{}, err + } + secret, digest, err := newSecret() + if err != nil { + return IssuedAPIKey{}, err + } + var issued IssuedAPIKey + err = s.storage.WithKeyIssuance(ctx, command.ProjectID, func(tx KeyIssuanceTx) error { + project, err := tx.LoadProject(ctx) + if err != nil { + return err + } + if project.Archived { + return ErrArchived + } + key, err := tx.ApplyAPIKey(ctx, NewAPIKey{ID: command.ID, Name: name, Prefix: secret[:keyPrefixLength], Digest: digest}) + if err != nil { + return err + } + issued = IssuedAPIKey{APIKey: key, Key: secret} + return nil + }) + if err != nil { + return IssuedAPIKey{}, err + } + return issued, nil +} + +// RevokeAPIKey revokes one key. Revoking a revoked key succeeds. +type RevokeAPIKey struct { + ProjectID, ID string +} + +func (s *Service) RevokeAPIKey(ctx context.Context, command RevokeAPIKey) error { + return s.storage.RevokeAPIKey(ctx, command.ProjectID, command.ID) +} diff --git a/services/core/internal/projects/service_test.go b/services/core/internal/projects/service_test.go new file mode 100644 index 00000000..d82cf6da --- /dev/null +++ b/services/core/internal/projects/service_test.go @@ -0,0 +1,165 @@ +package projects + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "errors" + "strings" + "testing" +) + +// fakeStorage fails the test on any call whose func is unset. +type fakeStorage struct { + t testing.TB + createProject func(context.Context, NewProject) (Project, error) + renameProject func(context.Context, string, string) (Project, error) + archiveProject func(context.Context, string) (Project, error) + withKeyIssuance func(context.Context, string, func(KeyIssuanceTx) error) error + revokeAPIKey func(context.Context, string, string) error +} + +func (f *fakeStorage) CreateProject(ctx context.Context, project NewProject) (Project, error) { + if f.createProject == nil { + f.t.Fatal("unexpected call to CreateProject") + } + return f.createProject(ctx, project) +} +func (f *fakeStorage) RenameProject(ctx context.Context, id, name string) (Project, error) { + if f.renameProject == nil { + f.t.Fatal("unexpected call to RenameProject") + } + return f.renameProject(ctx, id, name) +} +func (f *fakeStorage) ArchiveProject(ctx context.Context, id string) (Project, error) { + if f.archiveProject == nil { + f.t.Fatal("unexpected call to ArchiveProject") + } + return f.archiveProject(ctx, id) +} +func (f *fakeStorage) WithKeyIssuance(ctx context.Context, projectID string, issue func(KeyIssuanceTx) error) error { + if f.withKeyIssuance == nil { + f.t.Fatal("unexpected call to WithKeyIssuance") + } + return f.withKeyIssuance(ctx, projectID, issue) +} +func (f *fakeStorage) RevokeAPIKey(ctx context.Context, projectID, keyID string) error { + if f.revokeAPIKey == nil { + f.t.Fatal("unexpected call to RevokeAPIKey") + } + return f.revokeAPIKey(ctx, projectID, keyID) +} + +type fakeKeyIssuance struct { + t testing.TB + loadProject func(context.Context) (LockedProject, error) + applyAPIKey func(context.Context, NewAPIKey) (APIKey, error) +} + +func (f *fakeKeyIssuance) LoadProject(ctx context.Context) (LockedProject, error) { + if f.loadProject == nil { + f.t.Fatal("unexpected call to LoadProject") + } + return f.loadProject(ctx) +} +func (f *fakeKeyIssuance) ApplyAPIKey(ctx context.Context, key NewAPIKey) (APIKey, error) { + if f.applyAPIKey == nil { + f.t.Fatal("unexpected call to ApplyAPIKey") + } + return f.applyAPIKey(ctx, key) +} + +func newTestService(t *testing.T, storage *fakeStorage) *Service { + t.Helper() + storage.t = t + s, err := NewService(storage) + if err != nil { + t.Fatal(err) + } + return s +} + +func TestNewServiceRequiresStorage(t *testing.T) { + if _, err := NewService(nil); err == nil { + t.Fatal("nil storage accepted") + } +} + +func TestNamesValidateBeforeStorage(t *testing.T) { + s := newTestService(t, &fakeStorage{}) + for _, name := range []string{"", " ", "with\ncontrol", strings.Repeat("x", ProjectNameMaxLength+1)} { + var field *NameError + if _, err := s.CreateProject(t.Context(), CreateProject{ID: "11111111-1111-4111-8111-111111111111", Name: name}); !errors.As(err, &field) || field.MaxLength != ProjectNameMaxLength { + t.Fatal("invalid Project name accepted", err) + } + if _, err := s.RenameProject(t.Context(), RenameProject{ID: "11111111-1111-4111-8111-111111111111", Name: name}); !errors.As(err, &field) { + t.Fatal("invalid Project rename accepted", err) + } + } + for _, name := range []string{"", " ", "with\ncontrol", strings.Repeat("x", KeyNameMaxLength+1)} { + var field *NameError + if _, err := s.CreateAPIKey(t.Context(), CreateAPIKey{ProjectID: "11111111-1111-4111-8111-111111111111", ID: "22222222-2222-4222-8222-222222222222", Name: name}); !errors.As(err, &field) || field.MaxLength != KeyNameMaxLength { + t.Fatal("invalid key name accepted", err) + } + } +} + +func TestCreateProjectDerivesTheKeyPrincipal(t *testing.T) { + const id = "11111111-1111-4111-8111-111111111111" + var stored NewProject + s := newTestService(t, &fakeStorage{createProject: func(_ context.Context, project NewProject) (Project, error) { + stored = project + return Project{ID: project.ID, Name: project.Name}, nil + }}) + if _, err := s.CreateProject(t.Context(), CreateProject{ID: id, Name: " Default "}); err != nil { + t.Fatal(err) + } + if stored != (NewProject{ID: id, Name: "Default", OrganizationID: "core", ExternalProjectID: "proj_" + id, SubjectID: "project:" + id}) { + t.Fatalf("stored %#v", stored) + } +} + +func TestCreateAPIKeyStoresOnlyTheDigestOfAnActiveProjectKey(t *testing.T) { + var stored NewAPIKey + tx := &fakeKeyIssuance{ + loadProject: func(context.Context) (LockedProject, error) { return LockedProject{}, nil }, + applyAPIKey: func(_ context.Context, key NewAPIKey) (APIKey, error) { + stored = key + return APIKey{ID: key.ID, Name: key.Name, Prefix: key.Prefix}, nil + }, + } + tx.t = t + s := newTestService(t, &fakeStorage{withKeyIssuance: func(_ context.Context, projectID string, issue func(KeyIssuanceTx) error) error { + if projectID != "project" { + t.Fatal("wrong Project", projectID) + } + return issue(tx) + }}) + issued, err := s.CreateAPIKey(t.Context(), CreateAPIKey{ProjectID: "project", ID: "key", Name: " SDK "}) + if err != nil { + t.Fatal(err) + } + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(issued.Key, "pc_")) + if !strings.HasPrefix(issued.Key, "pc_") || err != nil || len(raw) != 32 { + t.Fatal("secret format changed", issued.Key) + } + if stored.ID != "key" || stored.Name != "SDK" || stored.Prefix != issued.Key[:11] || stored.Digest != sha256.Sum256([]byte(issued.Key)) || issued.Prefix != stored.Prefix { + t.Fatalf("stored %#v for %#v", stored, issued) + } +} + +func TestArchivedProjectIssuesNoKey(t *testing.T) { + tx := &fakeKeyIssuance{loadProject: func(context.Context) (LockedProject, error) { return LockedProject{Archived: true}, nil }} + tx.t = t + s := newTestService(t, &fakeStorage{withKeyIssuance: func(_ context.Context, _ string, issue func(KeyIssuanceTx) error) error { return issue(tx) }}) + if issued, err := s.CreateAPIKey(t.Context(), CreateAPIKey{ProjectID: "project", ID: "key", Name: "late"}); !errors.Is(err, ErrArchived) || issued.Key != "" { + t.Fatal("archived Project admitted a key", err) + } +} + +func TestStorageErrorsReturnWithoutASecret(t *testing.T) { + s := newTestService(t, &fakeStorage{withKeyIssuance: func(context.Context, string, func(KeyIssuanceTx) error) error { return ErrNotFound }}) + if issued, err := s.CreateAPIKey(t.Context(), CreateAPIKey{ProjectID: "missing", ID: "key", Name: "key"}); !errors.Is(err, ErrNotFound) || issued.Key != "" { + t.Fatal("failed issuance exposed a secret", err) + } +} diff --git a/services/core/internal/projects/storage.go b/services/core/internal/projects/storage.go new file mode 100644 index 00000000..ad2b4d54 --- /dev/null +++ b/services/core/internal/projects/storage.go @@ -0,0 +1,76 @@ +package projects + +import ( + "context" + "crypto/sha256" +) + +// Storage is the persistence the Project use cases write through. Each method +// runs in one transaction and records its administrator audit row in that +// transaction, from the source in the context; a missing or malformed source +// aborts the write. +type Storage interface { + // CreateProject stores the Project with a new tenant and its execution + // scope. An ID already in use returns ErrExists; a malformed ID returns + // ErrInvalidInput. + CreateProject(ctx context.Context, project NewProject) (Project, error) + // RenameProject returns ErrNotFound for a missing Project. + RenameProject(ctx context.Context, id, name string) (Project, error) + // ArchiveProject archives the Project and revokes all of its keys. Both are + // idempotent. It returns ErrNotFound for a missing Project. + ArchiveProject(ctx context.Context, id string) (Project, error) + // WithKeyIssuance runs issue while it holds a shared lock on the Project, so + // the Project cannot be archived until the new key commits. It returns + // ErrNotFound for a missing Project without calling issue. + WithKeyIssuance(ctx context.Context, projectID string, issue func(KeyIssuanceTx) error) error + // RevokeAPIKey revokes one key of the Project, idempotently. It returns + // ErrNotFound for a missing Project or a key of another Project. + RevokeAPIKey(ctx context.Context, projectID, keyID string) error +} + +// KeyIssuanceTx is one key issuance transaction. +type KeyIssuanceTx interface { + // LoadProject returns the state of the locked Project. + LoadProject(ctx context.Context) (LockedProject, error) + // ApplyAPIKey stores the key. An ID already in use returns + // ErrAPIKeyExists; a malformed ID returns ErrInvalidInput. + ApplyAPIKey(ctx context.Context, key NewAPIKey) (APIKey, error) +} + +// NewProject is a Project to store. The adapter allocates its tenant. +type NewProject struct { + ID, Name string + // OrganizationID and ExternalProjectID form the execution scope the + // Project's keys authenticate as, and SubjectID is their subject. + OrganizationID, ExternalProjectID, SubjectID string +} + +// LockedProject is the state a key issuance decides on. +type LockedProject struct { + Archived bool +} + +// NewAPIKey is a key to store: its display prefix and secret digest, never +// the secret. +type NewAPIKey struct { + ID, Name, Prefix string + Digest [sha256.Size]byte +} + +// Reader answers Project and key queries. +type Reader interface { + // GetProject returns ErrNotFound for a missing Project or malformed ID. + GetProject(ctx context.Context, id string) (Binding, error) + // ListProjects returns ErrNotFound when query.After names no Project. + ListProjects(ctx context.Context, query ListQuery) (Page, error) + // ListAPIKeys returns every key of the Project, including revoked keys. It + // returns ErrNotFound for a missing Project or when query.After names no key + // of this Project. + ListAPIKeys(ctx context.Context, projectID string, query ListQuery) (KeyPage, error) + // ResolveAPIKey returns the active key with this secret digest, in an + // active Project, or ErrNotFound. + ResolveAPIKey(ctx context.Context, digest [sha256.Size]byte) (KeyBinding, error) + // APIKeyDigestExists reports whether any key, including a revoked key or a + // key of an archived Project, has this secret digest. + APIKeyDigestExists(ctx context.Context, digest [sha256.Size]byte) (bool, error) +} diff --git a/services/core/internal/sandbox/providers/configuration_flow_test.go b/services/core/internal/sandbox/providers/configuration_flow_test.go index deddc459..f335cb84 100644 --- a/services/core/internal/sandbox/providers/configuration_flow_test.go +++ b/services/core/internal/sandbox/providers/configuration_flow_test.go @@ -103,7 +103,8 @@ func TestAdditionalConfigurationProviderUsesCommonAPIAndStore(t *testing.T) { // The flow reaches only the store areas and the deployment setup; every // other dependency panics if called. h, err := api.NewHandler(api.Dependencies{ - Engine: "codex", CoreKeys: auth, InstallationBindings: s, Projects: s, + Engine: "codex", CoreKeys: auth, InstallationBindings: s, + Projects: struct{ api.Projects }{}, ProjectsReader: struct{ api.ProjectsReader }{}, ModelProviders: struct{ api.ModelProviders }{}, ModelProvidersReader: struct{ api.ModelProvidersReader }{}, Vaults: struct{ api.Vaults }{}, VaultsReader: struct{ api.VaultsReader }{}, Files: struct{ api.Files }{}, FilesReader: struct{ api.FilesReader }{}, diff --git a/services/core/internal/store/admin_key_audit_test.go b/services/core/internal/store/admin_key_audit_test.go deleted file mode 100644 index 36477697..00000000 --- a/services/core/internal/store/admin_key_audit_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package store - -import ( - "context" - "errors" - "reflect" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/agents" - "github.com/google/uuid" -) - -func TestAdminProjectAndKeyDatabaseAuditTransactions(t *testing.T) { - s, pool := newManagedTestStore(t) - rejectAdminAuditInsert(t, s) - agentStore, agentService := testAgents(t, pool, fixtureCipher) - for _, action := range []string{"project_create", "rename", "archive", "key_create", "revoke"} { - t.Run(action, func(t *testing.T) { - projectID, keyID := uuid.NewString(), uuid.NewString() - var p Project - var issued IssuedProjectAPIKey - var asset agents.Agent - ctxFor := func(request string) context.Context { - return adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "87654321", ActorLabel: "administrator fixture", ProjectID: projectID, RequestID: request, TraceID: "admin-mutation-trace"}) - } - if action != "project_create" { - var err error - p, err = s.CreateProject(ctxFor(uuid.NewString()), projectID, "original") - if err != nil { - t.Fatal(err) - } - asset, err = agentService.Create(t.Context(), agents.CreateCommand{TenantID: p.TenantID, Configuration: []byte(`{"model":"fixture"}`)}) - if err != nil { - t.Fatal(err) - } - if action == "archive" || action == "revoke" { - issued, err = s.CreateProjectAPIKey(ctxFor(uuid.NewString()), projectID, keyID, "original key") - if err != nil { - t.Fatal(err) - } - } - } - mutate := func(request string) (Project, IssuedProjectAPIKey, error) { - ctx := ctxFor(request) - switch action { - case "project_create": - v, e := s.CreateProject(ctx, projectID, "created") - return v, IssuedProjectAPIKey{}, e - case "rename": - v, e := s.RenameProject(ctx, projectID, "renamed") - return v, IssuedProjectAPIKey{}, e - case "archive": - v, e := s.ArchiveProject(ctx, projectID) - return v, IssuedProjectAPIKey{}, e - case "key_create": - v, e := s.CreateProjectAPIKey(ctx, projectID, keyID, "created key") - return Project{}, v, e - default: - return Project{}, IssuedProjectAPIKey{}, s.RevokeProjectAPIKey(ctx, projectID, keyID) - } - } - tables := []string{"execution_project_scopes", "projects", "project_api_keys", "agents", "admin_audit_log", "write_audit_operations", "write_audit_owners"} - before := adminMutationSnapshot(t, s, tables...) - rejections := adminAuditRejections(t, s) - rejectedProject, rejectedKey, err := mutate(rejectedAdminRequest) - requireAdminAuditFailure(t, s, err, rejections) - if rejectedProject.ID != "" || rejectedKey.Key != "" { - t.Fatal("failed transaction exposed an uncommitted resource") - } - if !reflect.DeepEqual(before, adminMutationSnapshot(t, s, tables...)) { - t.Fatal("failed audit changed Project, key, asset, or audit state") - } - if issued.Key != "" { - if _, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(issued.Key)); err != nil { - t.Fatal("failed audit invalidated original key", err) - } - } - request := uuid.NewString() - result, key, err := mutate(request) - if err != nil { - t.Fatal(err) - } - if action == "project_create" { - p = result - } - expectedAction, kind, resource := action, "project", projectID - switch action { - case "project_create": - expectedAction = "create" - case "key_create": - expectedAction = "create" - kind = "api_key" - resource = keyID - case "revoke": - kind = "api_key" - resource = keyID - } - var count int - if err := s.pool.QueryRow(t.Context(), `SELECT count(*) FROM admin_audit_log WHERE project_id=$1 AND tenant_id=$2 AND request_id=$3 AND action=$4 AND resource_type=$5 AND resource_id=$6 AND admin_credential_id='87654321' AND actor_label='administrator fixture' AND trace_id='admin-mutation-trace'`, projectID, p.TenantID, request, expectedAction, kind, resource).Scan(&count); err != nil || count != 1 { - t.Fatal("successful mutation lacks matching audit", err) - } - if action == "key_create" { - if key.Key == "" { - t.Fatal("issuance lacks plaintext") - } - if _, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(key.Key)); err != nil { - t.Fatal(err) - } - } - if action == "archive" || action == "revoke" { - if _, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(issued.Key)); !errors.Is(err, ErrNotFound) { - t.Fatal("invalidated key authenticated", err) - } - } - if action != "project_create" { - if _, err := agentStore.GetAgent(t.Context(), p.TenantID, asset.ID); err != nil { - t.Fatal("management mutation removed assets", err) - } - } - }) - } -} diff --git a/services/core/internal/store/admin_session_archive_worker_http_test.go b/services/core/internal/store/admin_session_archive_worker_http_test.go index 9500daf6..19f24d20 100644 --- a/services/core/internal/store/admin_session_archive_worker_http_test.go +++ b/services/core/internal/store/admin_session_archive_worker_http_test.go @@ -11,6 +11,7 @@ import ( "sync" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" @@ -64,7 +65,8 @@ func TestAdminSessionArchiveWorkerHTTPPostgres(t *testing.T) { } projectID := uuid.NewString() ctx := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "fixture-admin", ProjectID: projectID, RequestID: uuid.NewString(), TraceID: uuid.NewString()}) - project, err := s.CreateProject(ctx, projectID, "Archive HTTP fixture") + _, management := fixtureProjects(t, db) + project, err := management.CreateProject(ctx, projects.CreateProject{ID: projectID, Name: "Archive HTTP fixture"}) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/admin_validation_test.go b/services/core/internal/store/admin_validation_test.go index 121d6c7e..e5dc0399 100644 --- a/services/core/internal/store/admin_validation_test.go +++ b/services/core/internal/store/admin_validation_test.go @@ -2,28 +2,12 @@ package store import ( "errors" - "fmt" "strings" "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) -func TestAdminNameValidationIdentityAndBoundaries(t *testing.T) { - for _, max := range []int{80, 128} { - for _, name := range []string{"", " ", "private-name\t", strings.Repeat("a", max+1), string([]byte{0xff})} { - _, err := adminResourceName(name, max) - var field *AdminValidationError - if !errors.As(err, &field) || !errors.Is(err, ErrInvalidInput) || field.Code != "invalid_name" || field.Param != "name" || field.MaxLength != max || err.Error() != fmt.Sprintf("%s: name must contain 1–%d characters without controls", ErrInvalidInput, max) { - t.Fatalf("wrong name error: %#v", err) - } - } - if got, err := adminResourceName(" "+strings.Repeat("界", max)+" ", max); err != nil || got != strings.Repeat("界", max) { - t.Fatal("rune/trim semantics changed", err) - } - } -} - func TestRuntimeNodeValidationIdentityAndPrecedence(t *testing.T) { for _, tc := range []struct { name string diff --git a/services/core/internal/store/archive_cancellation_test.go b/services/core/internal/store/archive_cancellation_test.go index da12554a..80a50531 100644 --- a/services/core/internal/store/archive_cancellation_test.go +++ b/services/core/internal/store/archive_cancellation_test.go @@ -13,6 +13,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/modelconfigurationpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -51,7 +52,8 @@ func TestArchiveWaitingCancellationReceipts(t *testing.T) { } projectID := uuid.NewString() auditCtx := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "fixture-admin", ProjectID: projectID, RequestID: uuid.NewString(), TraceID: uuid.NewString()}) - project, err := s.CreateProject(auditCtx, projectID, "Archive diagnosis") + _, management := fixtureProjects(t, db) + project, err := management.CreateProject(auditCtx, projects.CreateProject{ID: projectID, Name: "Archive diagnosis"}) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/auth_fixture_test.go b/services/core/internal/store/auth_fixture_test.go index 749e1b46..dc89198e 100644 --- a/services/core/internal/store/auth_fixture_test.go +++ b/services/core/internal/store/auth_fixture_test.go @@ -2,22 +2,23 @@ package store_test import ( "context" + "crypto/sha256" "encoding/hex" "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/google/uuid" ) type testAPIKey struct{ Name, TokenSHA256, TenantID, OrganizationID, ProjectID, SubjectKind, SubjectID string } -type fixtureKeyResolver map[string]store.ProjectAPIKeyBinding +type fixtureKeyResolver map[[sha256.Size]byte]projects.KeyBinding -func (f fixtureKeyResolver) ResolveProjectAPIKey(_ context.Context, digest string) (store.ProjectAPIKeyBinding, error) { +func (f fixtureKeyResolver) ResolveAPIKey(_ context.Context, digest [sha256.Size]byte) (projects.KeyBinding, error) { if b, ok := f[digest]; ok { return b, nil } - return store.ProjectAPIKeyBinding{}, store.ErrNotFound + return projects.KeyBinding{}, projects.ErrNotFound } // newTestAuthenticator binds each key's digest to its Principal, for @@ -30,13 +31,15 @@ func newTestAuthenticator(t testing.TB, keys []testAPIKey) fixtureKeyResolver { if err := p.Validate(); err != nil { t.Fatalf("invalid fixture principal: %v", err) } - if digest, err := hex.DecodeString(k.TokenSHA256); err != nil || len(digest) != 32 { + raw, err := hex.DecodeString(k.TokenSHA256) + if err != nil || len(raw) != sha256.Size { t.Fatalf("invalid fixture digest %q", k.TokenSHA256) } - if _, exists := resolver[k.TokenSHA256]; exists { + digest := [sha256.Size]byte(raw) + if _, exists := resolver[digest]; exists { t.Fatalf("duplicate fixture digest %q", k.TokenSHA256) } - resolver[k.TokenSHA256] = store.ProjectAPIKeyBinding{Key: store.ProjectAPIKey{ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte(k.TokenSHA256)).String(), Name: k.Name, Prefix: "pc_" + k.TokenSHA256[:8]}, Principal: p} + resolver[digest] = projects.KeyBinding{Key: projects.APIKey{ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte(k.TokenSHA256)).String(), Name: k.Name, Prefix: "pc_" + k.TokenSHA256[:8]}, Principal: p} } return resolver } diff --git a/services/core/internal/store/credential_matrix_http_test.go b/services/core/internal/store/credential_matrix_http_test.go index 89f76fd7..6af95bf4 100644 --- a/services/core/internal/store/credential_matrix_http_test.go +++ b/services/core/internal/store/credential_matrix_http_test.go @@ -12,6 +12,9 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/projectpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeenrollment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" @@ -60,13 +63,13 @@ func TestCredentialNamespaceMatrix(t *testing.T) { } // A Project and its API key, issued with the Core key. - var project store.Project + var project projects.Project created("POST", "/core/v1/projects", coreKey, `{"name":"Matrix"}`, &project) - var projectKey store.IssuedProjectAPIKey + var projectKey projects.IssuedAPIKey created("POST", "/core/v1/projects/"+project.ID+"/keys", coreKey, `{"name":"application"}`, &projectKey) // An executor credential for a self_hosted Session of that Project. - binding, err := s.GetProject(ctx, project.ID) + binding, err := projectpg.New(pgunit.NewPool(db.pool)).GetProject(ctx, project.ID) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/environment_executor_management.go b/services/core/internal/store/environment_executor_management.go index e2dfc399..2fa7b1c5 100644 --- a/services/core/internal/store/environment_executor_management.go +++ b/services/core/internal/store/environment_executor_management.go @@ -10,6 +10,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -113,7 +114,7 @@ func (s *Store) ProjectExecutorCredentialState(ctx context.Context, project iden // IssueProjectExecutorCredential issues a new key or, with rotate, replaces the // secret of an existing key restricted to the Environment. An archived Project -// gets neither (ErrProjectArchived). The administrator audit entry commits in +// gets neither (projects.ErrArchived). The administrator audit entry commits in // the same transaction and never contains the secret. func (s *Store) IssueProjectExecutorCredential(ctx context.Context, project identity.Principal, environment, keyID string, rotate bool) (IssuedExecutorCredential, error) { // The target is checked first, then the archived Project, then the key. @@ -178,7 +179,7 @@ func activeProjectAudit(project identity.Principal, action, keyID string) func(c } } -// activeProject returns ErrProjectArchived for an archived Project. +// activeProject returns projects.ErrArchived for an archived Project. func activeProject(ctx context.Context, q *sqlc.Queries, project identity.Principal) error { tenant, err := parseID(project.TenantID) if err != nil { @@ -192,7 +193,7 @@ func activeProject(ctx context.Context, q *sqlc.Queries, project identity.Princi return err } if row.ArchivedAt.Valid { - return ErrProjectArchived + return projects.ErrArchived } return nil } diff --git a/services/core/internal/store/environment_executor_management_test.go b/services/core/internal/store/environment_executor_management_test.go index 07d54d68..ebbf38de 100644 --- a/services/core/internal/store/environment_executor_management_test.go +++ b/services/core/internal/store/environment_executor_management_test.go @@ -8,23 +8,17 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/google/uuid" ) func TestProjectEnvironmentExecutorManagement(t *testing.T) { s, pool := testStore(t) ctx := t.Context() - project := createTestProject(t, s) - binding, err := s.GetProject(ctx, project.ID) - if err != nil { - t.Fatal(err) - } - p := binding.Principal - foreignProject := createTestProject(t, s) - foreign, err := s.GetProject(ctx, foreignProject.ID) - if err != nil { - t.Fatal(err) - } + binding := createTestProject(t, pool) + project, p := binding.Project, binding.Principal + foreign := createTestProject(t, pool) + foreignProject := foreign.Project // Each administrator request has its own request ID. admin := func() context.Context { return keyAdminContext(ctx, project.ID) } create := func(kind string) (Session, Environment) { @@ -148,14 +142,10 @@ func TestProjectEnvironmentExecutorManagement(t *testing.T) { // An archived Project gets no new or rotated credential; listing and revoking still work. func TestArchivedProjectExecutorCredentials(t *testing.T) { - s, _ := testStore(t) + s, pool := testStore(t) ctx := t.Context() - project := createTestProject(t, s) - binding, err := s.GetProject(ctx, project.ID) - if err != nil { - t.Fatal(err) - } - p := binding.Principal + binding := createTestProject(t, pool) + project, p := binding.Project, binding.Principal input := environmentInput(uuid.NewString(), "self_hosted", "/workspace") input.Creator = p.Subject() session, err := s.CreateSession(ctx, p.TenantID, input) @@ -170,7 +160,7 @@ func TestArchivedProjectExecutorCredentials(t *testing.T) { if _, err := s.IssueProjectExecutorCredential(keyAdminContext(ctx, project.ID), p, environment.ID, keyID, false); err != nil { t.Fatal(err) } - if _, err := s.ArchiveProject(keyAdminContext(ctx, project.ID), project.ID); err != nil { + if _, err := testProjects(t, pool).ArchiveProject(keyAdminContext(ctx, project.ID), projects.ArchiveProject{ID: project.ID}); err != nil { t.Fatal(err) } // Order: the target (404) first, then the archived Project (409), before @@ -181,10 +171,10 @@ func TestArchivedProjectExecutorCredentials(t *testing.T) { want error }{ {uuid.NewString(), uuid.NewString(), false, ErrNotFound}, - {environment.ID, uuid.NewString(), false, ErrProjectArchived}, - {environment.ID, keyID, false, ErrProjectArchived}, - {environment.ID, keyID, true, ErrProjectArchived}, - {environment.ID, uuid.NewString(), true, ErrProjectArchived}, + {environment.ID, uuid.NewString(), false, projects.ErrArchived}, + {environment.ID, keyID, false, projects.ErrArchived}, + {environment.ID, keyID, true, projects.ErrArchived}, + {environment.ID, uuid.NewString(), true, projects.ErrArchived}, } { if _, err := s.IssueProjectExecutorCredential(keyAdminContext(ctx, project.ID), p, test.environment, test.key, test.rotate); !errors.Is(err, test.want) { t.Fatal("archived write", test, err) @@ -204,11 +194,8 @@ func TestArchivedProjectExecutorCredentials(t *testing.T) { func TestProjectExecutorConnectionState(t *testing.T) { s, pool := testStore(t) ctx := t.Context() - project := createTestProject(t, s) - binding, err := s.GetProject(ctx, project.ID) - if err != nil { - t.Fatal(err) - } + binding := createTestProject(t, pool) + project := binding.Project session, env, key := runtimeEnrollmentFixture(t, s, binding.Principal) state, err := s.ProjectExecutorCredentialState(ctx, binding.Principal, env.ID) if err != nil || state.Connection.DeviceID != "" || state.Connection.EnrolledAt != nil || state.Connection.BoundKeyID != nil || state.Connection.LastSeenAt != nil { @@ -292,11 +279,7 @@ func TestProjectExecutorConnectionState(t *testing.T) { if state.Connection.EnvironmentStatus != "expired" || state.Connection.CredentialHash != "" { t.Fatal("expired authority") } - foreignProject := createTestProject(t, s) - foreign, err := s.GetProject(ctx, foreignProject.ID) - if err != nil { - t.Fatal(err) - } + foreign := createTestProject(t, pool) for _, id := range []string{uuid.NewString(), "malformed"} { if _, err = s.ProjectExecutorCredentialState(ctx, binding.Principal, id); !errors.Is(err, ErrNotFound) { t.Fatal("missing target", err) diff --git a/services/core/internal/store/environment_installation_test.go b/services/core/internal/store/environment_installation_test.go index 389ae4c6..2b4e3aff 100644 --- a/services/core/internal/store/environment_installation_test.go +++ b/services/core/internal/store/environment_installation_test.go @@ -22,12 +22,7 @@ func TestEnvironmentInstallationClaimLifetimeAndRetries(t *testing.T) { } s := NewWithCredentialCipher(pool, cipher) ctx := t.Context() - project := createTestProject(t, s) - binding, err := s.GetProject(ctx, project.ID) - if err != nil { - t.Fatal(err) - } - p := binding.Principal + p := createTestProject(t, pool).Principal input := environmentInput(uuid.NewString(), "self_hosted", "/workspace") input.Creator = p.Subject() session, err := s.CreateSession(ctx, p.TenantID, input) diff --git a/services/core/internal/store/project_api_keys.go b/services/core/internal/store/project_api_keys.go deleted file mode 100644 index 1d5dc0a0..00000000 --- a/services/core/internal/store/project_api_keys.go +++ /dev/null @@ -1,195 +0,0 @@ -package store - -import ( - "context" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "errors" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" -) - -var ErrProjectAPIKeyExists = errors.New("project API key ID already exists") - -type ProjectAPIKey struct { - ID string `json:"id"` - ProjectID string `json:"project_id"` - Name string `json:"name"` - Prefix string `json:"prefix"` - CreatedAt time.Time `json:"created_at"` - RevokedAt *time.Time `json:"revoked_at"` -} -type IssuedProjectAPIKey struct { - ProjectAPIKey - Key string `json:"key"` -} -type ProjectAPIKeyBinding struct { - Key ProjectAPIKey - Principal identity.Principal -} -type ProjectAPIKeyPage struct { - Data []ProjectAPIKey `json:"data"` - HasMore bool `json:"has_more"` -} - -func projectKeyMetadata(row sqlc.ProjectApiKey) ProjectAPIKey { - key := ProjectAPIKey{ID: uuid.UUID(row.ID.Bytes).String(), ProjectID: uuid.UUID(row.ProjectID.Bytes).String(), Name: row.Name, Prefix: row.Prefix, CreatedAt: row.CreatedAt.Time} - if row.RevokedAt.Valid { - v := row.RevokedAt.Time - key.RevokedAt = &v - } - return key -} -func newProjectSecret() (string, string, error) { - raw := make([]byte, 32) - if _, err := rand.Read(raw); err != nil { - return "", "", err - } - token := "pc_" + base64.RawURLEncoding.EncodeToString(raw) - digest := sha256.Sum256([]byte(token)) - return token, hex.EncodeToString(digest[:]), nil -} -func (s *Store) CreateProjectAPIKey(ctx context.Context, project, id, name string) (IssuedProjectAPIKey, error) { - projectID, err := parseID(project) - if err != nil { - return IssuedProjectAPIKey{}, ErrNotFound - } - keyID, err := parseID(id) - if err != nil { - return IssuedProjectAPIKey{}, err - } - name, err = adminResourceName(name, 80) - if err != nil { - return IssuedProjectAPIKey{}, err - } - token, digest, err := newProjectSecret() - if err != nil { - return IssuedProjectAPIKey{}, err - } - var result IssuedProjectAPIKey - err = s.pooled.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { - q := s.queries.WithTx(tx) - p, err := q.LockProject(ctx, projectID) - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - if p.ArchivedAt.Valid { - return ErrProjectArchived - } - row, err := q.CreateProjectAPIKey(ctx, sqlc.CreateProjectAPIKeyParams{ID: keyID, Name: name, Prefix: token[:11], TokenSha256: digest, ProjectID: projectID}) - if errors.Is(err, pgx.ErrNoRows) { - return ErrProjectAPIKeyExists - } - if err != nil { - return err - } - result = IssuedProjectAPIKey{ProjectAPIKey: projectKeyMetadata(row), Key: token} - return auditpg.RecordAdminMutation(ctx, q, uuid.UUID(p.TenantID.Bytes).String(), "create", "api_key", id) - }) - if err != nil { - return IssuedProjectAPIKey{}, err - } - return result, nil -} -func (s *Store) ListProjectAPIKeys(ctx context.Context, project, after string, limit int, ascending bool) (ProjectAPIKeyPage, error) { - result := ProjectAPIKeyPage{Data: []ProjectAPIKey{}} - if limit < 1 || limit > 100 { - return result, ErrInvalidInput - } - p, err := s.GetProject(ctx, project) - if err != nil { - return result, err - } - projectID, _ := parseID(p.Project.ID) - if after != "" { - id, err := parseID(after) - if err != nil { - return result, ErrNotFound - } - if _, err := s.queries.GetProjectAPIKeyForProject(ctx, sqlc.GetProjectAPIKeyForProjectParams{ID: id, ProjectID: projectID}); errors.Is(err, pgx.ErrNoRows) { - return result, ErrNotFound - } else if err != nil { - return result, err - } - } - rows, err := s.queries.ListProjectAPIKeys(ctx, sqlc.ListProjectAPIKeysParams{ProjectID: projectID, AfterID: after, Ascending: ascending, PageLimit: int32(limit + 1)}) - if err != nil { - return result, err - } - result.HasMore = len(rows) > limit - if result.HasMore { - rows = rows[:limit] - } - for _, row := range rows { - result.Data = append(result.Data, projectKeyMetadata(row)) - } - return result, nil -} -func (s *Store) RevokeProjectAPIKey(ctx context.Context, project, id string) error { - projectID, err := parseID(project) - if err != nil { - return ErrNotFound - } - keyID, err := parseID(id) - if err != nil { - return ErrNotFound - } - return s.pooled.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { - q := s.queries.WithTx(tx) - p, err := q.LockProject(ctx, projectID) - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - _, err = q.RevokeProjectAPIKey(ctx, sqlc.RevokeProjectAPIKeyParams{ID: keyID, ProjectID: projectID}) - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - return auditpg.RecordAdminMutation(ctx, q, uuid.UUID(p.TenantID.Bytes).String(), "revoke", "api_key", id) - }) -} -func (s *Store) ResolveProjectAPIKey(ctx context.Context, digest string) (ProjectAPIKeyBinding, error) { - if !writeaudit.ValidKeyDigest(digest) { - return ProjectAPIKeyBinding{}, ErrNotFound - } - row, err := s.queries.ResolveProjectAPIKey(ctx, digest) - if errors.Is(err, pgx.ErrNoRows) { - return ProjectAPIKeyBinding{}, ErrNotFound - } - if err != nil { - return ProjectAPIKeyBinding{}, err - } - key := projectKeyMetadata(sqlc.ProjectApiKey{ID: row.ID, ProjectID: row.ProjectID, Name: row.Name, Prefix: row.Prefix, TokenSha256: row.TokenSha256, CreatedAt: row.CreatedAt, RevokedAt: row.RevokedAt}) - return ProjectAPIKeyBinding{Key: key, Principal: identity.Principal{ProjectScope: identity.ProjectScope{TenantID: uuid.UUID(row.TenantID.Bytes).String(), OrganizationID: row.OrganizationID, ProjectID: row.ExternalProjectID}, SubjectKind: row.SubjectKind, SubjectID: row.SubjectID}}, nil -} - -// ValidateProjectKeySeparation checks configured credentials against all stored -// digests, including revoked credentials, before the server starts. -func (s *Store) ValidateProjectKeySeparation(ctx context.Context, digests []string) error { - for _, digest := range digests { - exists, err := s.queries.ProjectAPIKeyDigestExists(ctx, digest) - if err != nil { - return err - } - if exists { - return errors.New("the Core key overlaps a persisted project API key") - } - } - return nil -} diff --git a/services/core/internal/store/project_api_keys_http_test.go b/services/core/internal/store/project_api_keys_http_test.go index a64f773b..df0aba6a 100644 --- a/services/core/internal/store/project_api_keys_http_test.go +++ b/services/core/internal/store/project_api_keys_http_test.go @@ -7,8 +7,8 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -38,12 +38,12 @@ func TestProjectAndSharedKeysHTTPManagement(t *testing.T) { } base := "/core/v1/projects" response := call("POST", base, adminToken, `{"name":"Default"}`, 201) - var p store.Project + var p projects.Project if json.Unmarshal(response.Body.Bytes(), &p) != nil || p.ID == "" { t.Fatal("Project response invalid") } keysPath := base + "/" + p.ID + "/keys" - var first, second store.IssuedProjectAPIKey + var first, second projects.IssuedAPIKey if json.Unmarshal(call("POST", keysPath, adminToken, `{"name":"first"}`, 201).Body.Bytes(), &first) != nil { t.Fatal("key response invalid") } diff --git a/services/core/internal/store/project_api_keys_test.go b/services/core/internal/store/project_api_keys_test.go deleted file mode 100644 index f82c5519..00000000 --- a/services/core/internal/store/project_api_keys_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package store - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/agents" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" - "github.com/google/uuid" -) - -func projectKeyDigest(value string) string { - digest := sha256.Sum256([]byte(value)) - return hex.EncodeToString(digest[:]) -} -func projectKeyPrincipal() identity.Principal { - return identity.Principal{ProjectScope: identity.ProjectScope{TenantID: uuid.NewString(), OrganizationID: "org-" + uuid.NewString(), ProjectID: "project-" + uuid.NewString()}, SubjectKind: "service_account", SubjectID: "test"} -} -func keyAdminContext(ctx context.Context, id string) context.Context { - return adminaudit.WithSource(ctx, adminaudit.Source{CredentialID: "12345678", ActorLabel: "test", RequestID: uuid.NewString(), TraceID: uuid.NewString(), ProjectID: id}) -} -func createTestProject(t *testing.T, s *Store) Project { - t.Helper() - id := uuid.NewString() - p, err := s.CreateProject(keyAdminContext(t.Context(), id), id, "Project") - if err != nil { - t.Fatal(err) - } - return p -} -func TestProjectKeysShareIdentityAndArchiveRetainsAssets(t *testing.T) { - s, pool := testStore(t) - p := createTestProject(t, s) - other := createTestProject(t, s) - first, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), p.ID), p.ID, uuid.NewString(), "first") - if err != nil { - t.Fatal(err) - } - second, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), p.ID), p.ID, uuid.NewString(), "second") - if err != nil { - t.Fatal(err) - } - a, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(first.Key)) - if err != nil { - t.Fatal(err) - } - b, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(second.Key)) - if err != nil || a.Principal != b.Principal || a.Principal.SubjectID != "project:"+p.ID || a.Principal.ProjectID != "proj_"+p.ID { - t.Fatal("Project keys do not share the stable Project principal", err) - } - agentStore, agentService := testAgents(t, pool, nil) - asset, err := agentService.Create(t.Context(), agents.CreateCommand{TenantID: a.Principal.TenantID, Configuration: []byte(`{"model":"test"}`)}) - if err != nil { - t.Fatal(err) - } - if _, err := agentStore.GetAgent(t.Context(), b.Principal.TenantID, asset.ID); err != nil { - t.Fatal("peer key cannot read shared asset", err) - } - if _, err := agentStore.GetAgent(t.Context(), other.TenantID, asset.ID); !errors.Is(err, agents.ErrNotFound) { - t.Fatal("foreign Project read asset", err) - } - renamed, err := s.RenameProject(keyAdminContext(t.Context(), p.ID), p.ID, "renamed") - if err != nil || renamed.TenantID != p.TenantID || renamed.ActiveKeyCount != 2 { - t.Fatal("rename changed Project ownership", err) - } - afterRename, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(first.Key)) - if err != nil || afterRename.Principal != a.Principal { - t.Fatal("rename changed key principal", err) - } - listed, err := s.ListProjectAPIKeys(t.Context(), p.ID, "", 50, true) - if err != nil || len(listed.Data) != 2 { - t.Fatal("Project keys missing", err) - } - raw, _ := json.Marshal(listed) - if strings.Contains(string(raw), first.Key) || strings.Contains(string(raw), projectKeyDigest(first.Key)) { - t.Fatal("key list exposed credential") - } - if err := s.RevokeProjectAPIKey(keyAdminContext(t.Context(), other.ID), other.ID, first.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign Project revoked key", err) - } - if err := s.RevokeProjectAPIKey(keyAdminContext(t.Context(), p.ID), p.ID, first.ID); err != nil { - t.Fatal(err) - } - if _, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(first.Key)); !errors.Is(err, ErrNotFound) { - t.Fatal("revoked key authenticated") - } - if _, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(second.Key)); err != nil { - t.Fatal("revocation affected peer", err) - } - archived, err := s.ArchiveProject(keyAdminContext(t.Context(), p.ID), p.ID) - if err != nil || archived.ArchivedAt == nil || archived.ActiveKeyCount != 0 { - t.Fatal("archive did not revoke all keys", err) - } - if _, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(second.Key)); !errors.Is(err, ErrNotFound) { - t.Fatal("archived Project authenticated") - } - if _, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), p.ID), p.ID, uuid.NewString(), "late"); !errors.Is(err, ErrProjectArchived) { - t.Fatal("archived Project admitted new key", err) - } - if _, err := agentStore.GetAgent(t.Context(), p.TenantID, asset.ID); err != nil { - t.Fatal("archive removed assets", err) - } - if err := s.ValidateProjectKeySeparation(t.Context(), []string{projectKeyDigest(second.Key)}); err == nil { - t.Fatal("revoked credential collision accepted") - } - raw, _ = json.Marshal(archived) - if strings.Contains(string(raw), p.TenantID) { - t.Fatal("Project response exposed internal tenant") - } -} -func TestProjectManagementRequiresAtomicAudit(t *testing.T) { - s, _ := testStore(t) - id := uuid.NewString() - if _, err := s.CreateProject(t.Context(), id, "unaudited"); !errors.Is(err, adminaudit.ErrInvalidSource) { - t.Fatal("unaudited Project accepted") - } - if _, err := s.GetProject(t.Context(), id); !errors.Is(err, ErrNotFound) { - t.Fatal("unaudited Project persisted") - } - p := createTestProject(t, s) - keyID := uuid.NewString() - if _, err := s.CreateProjectAPIKey(t.Context(), p.ID, keyID, "unaudited"); !errors.Is(err, adminaudit.ErrInvalidSource) { - t.Fatal("unaudited key accepted") - } - page, err := s.ListProjectAPIKeys(t.Context(), p.ID, "", 20, true) - if err != nil || len(page.Data) != 0 { - t.Fatal("unaudited key persisted") - } - if _, err := s.RenameProject(t.Context(), p.ID, "bad"); !errors.Is(err, adminaudit.ErrInvalidSource) { - t.Fatal("unaudited rename accepted") - } - if _, err := s.ArchiveProject(t.Context(), p.ID); !errors.Is(err, adminaudit.ErrInvalidSource) { - t.Fatal("unaudited archive accepted") - } - current, err := s.GetProject(t.Context(), p.ID) - if err != nil || current.Project.Name != p.Name || current.Project.ArchivedAt != nil { - t.Fatal("unaudited mutation persisted") - } -} -func TestProjectNamesValidateBeforeDatabaseAccess(t *testing.T) { - s := &Store{} - for _, name := range []string{"", " ", "with\ncontrol", strings.Repeat("x", 129)} { - if _, err := s.CreateProject(t.Context(), uuid.NewString(), name); !errors.Is(err, ErrInvalidInput) { - t.Fatal("invalid Project name accepted") - } - } - for _, name := range []string{"", " ", "with\ncontrol", strings.Repeat("x", 81)} { - if _, err := s.CreateProjectAPIKey(t.Context(), uuid.NewString(), uuid.NewString(), name); !errors.Is(err, ErrInvalidInput) { - t.Fatal("invalid key name accepted") - } - } -} - -func TestProjectCatalogPaginationAndScopedKeyCursor(t *testing.T) { - s, _ := testStore(t) - first, second := createTestProject(t, s), createTestProject(t, s) - if _, err := s.CreateProject(keyAdminContext(t.Context(), first.ID), first.ID, "duplicate"); !errors.Is(err, ErrProjectExists) { - t.Fatal("duplicate Project ID did not conflict", err) - } - // Equal display names do not merge Projects or keys. - a, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), first.ID), first.ID, uuid.NewString(), "same name") - if err != nil { - t.Fatal(err) - } - b, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), first.ID), first.ID, uuid.NewString(), "same name") - if err != nil { - t.Fatal(err) - } - if _, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), first.ID), first.ID, a.ID, "duplicate ID"); !errors.Is(err, ErrProjectAPIKeyExists) { - t.Fatal("duplicate key ID did not conflict", err) - } - page, err := s.ListProjectAPIKeys(t.Context(), first.ID, "", 1, true) - if err != nil || len(page.Data) != 1 || !page.HasMore { - t.Fatal("first key page invalid", err) - } - tail, err := s.ListProjectAPIKeys(t.Context(), first.ID, page.Data[0].ID, 1, true) - if err != nil || len(tail.Data) != 1 || tail.HasMore || tail.Data[0].ID <= page.Data[0].ID { - t.Fatal("key cursor did not advance", err) - } - reverse, err := s.ListProjectAPIKeys(t.Context(), first.ID, "", 1, false) - if err != nil || len(reverse.Data) != 1 || reverse.Data[0].ID != tail.Data[0].ID { - t.Fatal("descending key page invalid", err) - } - if _, err := s.ListProjectAPIKeys(t.Context(), second.ID, a.ID, 1, true); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign key cursor accepted", err) - } - if _, err := s.ListProjectAPIKeys(t.Context(), first.ID, "", 101, true); !errors.Is(err, ErrInvalidInput) { - t.Fatal("oversized key page accepted", err) - } - if _, err := s.ListProjects(t.Context(), "", 101, true); !errors.Is(err, ErrInvalidInput) { - t.Fatal("oversized Project page accepted", err) - } - if err := s.RevokeProjectAPIKey(keyAdminContext(t.Context(), first.ID), first.ID, b.ID); err != nil { - t.Fatal(err) - } - if err := s.RevokeProjectAPIKey(keyAdminContext(t.Context(), first.ID), first.ID, a.ID); err != nil { - t.Fatal(err) - } - current, err := s.GetProject(t.Context(), first.ID) - if err != nil || current.Project.ArchivedAt != nil || current.Project.ActiveKeyCount != 0 { - t.Fatal("last key removal changed Project lifecycle", err) - } - if _, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), first.ID), first.ID, uuid.NewString(), "new access"); err != nil { - t.Fatal("zero-key Project could not issue another key", err) - } -} diff --git a/services/core/internal/store/projects.go b/services/core/internal/store/projects.go deleted file mode 100644 index d6d00f81..00000000 --- a/services/core/internal/store/projects.go +++ /dev/null @@ -1,182 +0,0 @@ -package store - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - "unicode" - "unicode/utf8" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" -) - -var ( - ErrProjectArchived = errors.New("project is archived") - ErrProjectExists = errors.New("project ID already exists") -) - -type Project struct { - ID string `json:"id"` - Name string `json:"name"` - CreatedAt time.Time `json:"created_at"` - ArchivedAt *time.Time `json:"archived_at"` - ActiveKeyCount int64 `json:"active_key_count"` - TenantID string `json:"-"` -} -type ProjectBinding struct { - Project Project - Principal identity.Principal -} -type ProjectPage struct { - Data []Project `json:"data"` - HasMore bool `json:"has_more"` -} - -func projectBinding(row sqlc.GetProjectRow) ProjectBinding { - p := Project{ID: uuid.UUID(row.ID.Bytes).String(), Name: row.Name, TenantID: uuid.UUID(row.TenantID.Bytes).String(), CreatedAt: row.CreatedAt.Time, ActiveKeyCount: row.ActiveKeyCount} - if row.ArchivedAt.Valid { - t := row.ArchivedAt.Time - p.ArchivedAt = &t - } - return ProjectBinding{Project: p, Principal: identity.Principal{ProjectScope: identity.ProjectScope{TenantID: p.TenantID, OrganizationID: row.OrganizationID, ProjectID: row.ExternalProjectID}, SubjectKind: row.SubjectKind, SubjectID: row.SubjectID}} -} -func adminResourceName(value string, max int) (string, error) { - control := strings.ContainsFunc(value, unicode.IsControl) - value = strings.TrimSpace(value) - if value == "" || !utf8.ValidString(value) || utf8.RuneCountInString(value) > max || control { - return "", &AdminValidationError{Code: "invalid_name", Param: "name", MaxLength: max, message: fmt.Sprintf("%s: name must contain 1–%d characters without controls", ErrInvalidInput, max)} - } - return value, nil -} -func (s *Store) CreateProject(ctx context.Context, id, name string) (Project, error) { - projectID, err := parseID(id) - if err != nil { - return Project{}, err - } - name, err = adminResourceName(name, 128) - if err != nil { - return Project{}, err - } - tenantID, _ := parseID(uuid.NewString()) - var result Project - err = s.pooled.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { - q := s.queries.WithTx(tx) - _, err := q.EnsureProjectScope(ctx, sqlc.EnsureProjectScopeParams{TenantID: tenantID, OrganizationID: "core", ProjectID: "proj_" + id}) - if err != nil { - var db *pgconn.PgError - if errors.As(err, &db) && db.Code == "23505" { - return ErrProjectExists - } - return err - } - _, err = q.CreateProject(ctx, sqlc.CreateProjectParams{ID: projectID, Name: name, TenantID: tenantID, SubjectID: "project:" + id}) - if err != nil { - return err - } - row, err := q.GetProject(ctx, projectID) - if err != nil { - return err - } - result = projectBinding(row).Project - return auditpg.RecordAdminMutation(ctx, q, result.TenantID, "create", "project", id) - }) - if err != nil { - return Project{}, err - } - return result, nil -} -func (s *Store) GetProject(ctx context.Context, id string) (ProjectBinding, error) { - projectID, err := parseID(id) - if err != nil { - return ProjectBinding{}, ErrNotFound - } - row, err := s.queries.GetProject(ctx, projectID) - if errors.Is(err, pgx.ErrNoRows) { - err = ErrNotFound - } - if err != nil { - return ProjectBinding{}, err - } - return projectBinding(row), nil -} -func (s *Store) ListProjects(ctx context.Context, after string, limit int, ascending bool) (ProjectPage, error) { - result := ProjectPage{Data: []Project{}} - if limit < 1 || limit > 100 { - return result, ErrInvalidInput - } - if after != "" { - if _, err := s.GetProject(ctx, after); err != nil { - return result, err - } - } - rows, err := s.queries.ListProjects(ctx, sqlc.ListProjectsParams{AfterID: after, Ascending: ascending, PageLimit: int32(limit + 1)}) - if err != nil { - return result, err - } - result.HasMore = len(rows) > limit - if result.HasMore { - rows = rows[:limit] - } - for _, row := range rows { - result.Data = append(result.Data, projectBinding(sqlc.GetProjectRow(row)).Project) - } - return result, nil -} -func (s *Store) RenameProject(ctx context.Context, id, name string) (Project, error) { - name, err := adminResourceName(name, 128) - if err != nil { - return Project{}, err - } - return s.mutateProject(ctx, id, name, false) -} -func (s *Store) ArchiveProject(ctx context.Context, id string) (Project, error) { - return s.mutateProject(ctx, id, "", true) -} -func (s *Store) mutateProject(ctx context.Context, id, name string, archive bool) (Project, error) { - projectID, err := parseID(id) - if err != nil { - return Project{}, ErrNotFound - } - var result Project - err = s.pooled.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { - q := s.queries.WithTx(tx) - _, err := q.LockProjectForUpdate(ctx, projectID) - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - action := "rename" - if archive { - action = "archive" - if err := q.ArchiveProject(ctx, projectID); err != nil { - return err - } - if err := q.RevokeProjectKeys(ctx, projectID); err != nil { - return err - } - } else { - if err := q.RenameProject(ctx, sqlc.RenameProjectParams{ID: projectID, Name: name}); err != nil { - return err - } - } - row, err := q.GetProject(ctx, projectID) - if err != nil { - return err - } - result = projectBinding(row).Project - return auditpg.RecordAdminMutation(ctx, q, result.TenantID, action, "project", id) - }) - if err != nil { - return Project{}, err - } - return result, nil -} diff --git a/services/core/internal/store/projects_fixture_test.go b/services/core/internal/store/projects_fixture_test.go new file mode 100644 index 00000000..838452c1 --- /dev/null +++ b/services/core/internal/store/projects_fixture_test.go @@ -0,0 +1,41 @@ +package store + +import ( + "context" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/projectpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +func keyAdminContext(ctx context.Context, id string) context.Context { + return adminaudit.WithSource(ctx, adminaudit.Source{CredentialID: "12345678", ActorLabel: "test", RequestID: uuid.NewString(), TraceID: uuid.NewString(), ProjectID: id}) +} + +// testProjects manages Projects in the test database, as production does. +func testProjects(t testing.TB, pool *pgxpool.Pool) *projects.Service { + t.Helper() + service, err := projects.NewService(projectpg.New(pgunit.NewPool(pool))) + if err != nil { + t.Fatal(err) + } + return service +} + +// createTestProject creates an audited Project and returns its binding. +func createTestProject(t testing.TB, pool *pgxpool.Pool) projects.Binding { + t.Helper() + id := uuid.NewString() + if _, err := testProjects(t, pool).CreateProject(keyAdminContext(t.Context(), id), projects.CreateProject{ID: id, Name: "Project"}); err != nil { + t.Fatal(err) + } + binding, err := projectpg.New(pgunit.NewPool(pool)).GetProject(t.Context(), id) + if err != nil { + t.Fatal(err) + } + return binding +} diff --git a/services/core/internal/store/projects_public_fixture_test.go b/services/core/internal/store/projects_public_fixture_test.go new file mode 100644 index 00000000..e861a47f --- /dev/null +++ b/services/core/internal/store/projects_public_fixture_test.go @@ -0,0 +1,33 @@ +package store_test + +import ( + "context" + "crypto/sha256" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/projectpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" +) + +// fixtureProjects builds the Project adapter and service on db. +func fixtureProjects(t testing.TB, db fixtureDB) (*projectpg.Store, *projects.Service) { + t.Helper() + projectStore := projectpg.New(pgunit.NewPool(db.pool)) + projectService, err := projects.NewService(projectStore) + if err != nil { + t.Fatal(err) + } + return projectStore, projectService +} + +// fixtureProjectsReader reads Projects from the database and resolves Project +// keys from the test's fixture keys. +type fixtureProjectsReader struct { + projects.Reader + keys fixtureKeyResolver +} + +func (p fixtureProjectsReader) ResolveAPIKey(ctx context.Context, digest [sha256.Size]byte) (projects.KeyBinding, error) { + return p.keys.ResolveAPIKey(ctx, digest) +} diff --git a/services/core/internal/store/public_handler_fixture_test.go b/services/core/internal/store/public_handler_fixture_test.go index 35143f4a..48a56841 100644 --- a/services/core/internal/store/public_handler_fixture_test.go +++ b/services/core/internal/store/public_handler_fixture_test.go @@ -68,9 +68,10 @@ func publicHandler(t testing.TB, s *store.Store, db fixtureDB, keys fixtureKeyRe if err != nil { return nil, err } + projectStore, projectService := fixtureProjects(t, db) deps := api.Dependencies{ Engine: engine, CoreKeys: admin, InstallationBindings: s, - Projects: fixtureProjects{Store: s, keys: keys}, + Projects: projectService, ProjectsReader: fixtureProjectsReader{Reader: projectStore, keys: keys}, ModelProviders: modelConfigurationService, ModelProvidersReader: modelConfigurationStore, Vaults: vaultService, VaultsReader: vaultStore, Files: fileService, FilesReader: fileStore, @@ -109,20 +110,9 @@ func fixtureFiles(t testing.TB, db fixtureDB) (*filepg.Store, *files.Service) { return fileStore, fileService } -// fixtureProjects serves Projects from the Store and resolves Project keys from -// the test's fixture keys. -type fixtureProjects struct { - *store.Store - keys fixtureKeyResolver -} - -func (p fixtureProjects) ResolveProjectAPIKey(ctx context.Context, digest string) (store.ProjectAPIKeyBinding, error) { - return p.keys.ResolveProjectAPIKey(ctx, digest) -} - -// storeKeys resolves Project keys from the Store, as production does. -func storeKeys(s *store.Store) func(*api.Dependencies) { - return func(d *api.Dependencies) { d.Projects = s } +// storeKeys resolves Project keys from the database, as production does. +func storeKeys(*store.Store) func(*api.Dependencies) { + return func(d *api.Dependencies) { d.ProjectsReader = d.ProjectsReader.(fixtureProjectsReader).Reader } } // withCoreKeys replaces the Core key. diff --git a/services/core/internal/store/stream_authority_http_test.go b/services/core/internal/store/stream_authority_http_test.go index 782e617d..842166d7 100644 --- a/services/core/internal/store/stream_authority_http_test.go +++ b/services/core/internal/store/stream_authority_http_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -22,17 +23,18 @@ func TestLiveStreamClosesAfterKeyRevocationOrProjectArchive(t *testing.T) { } t.Run(name, func(t *testing.T) { s, db := newTestStoreDB(t) + _, management := fixtureProjects(t, db) projectID := uuid.NewString() ctx := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "12345678", ActorLabel: "test", RequestID: uuid.NewString(), TraceID: uuid.NewString(), ProjectID: projectID}) - project, err := s.CreateProject(ctx, projectID, "Stream authority") + project, err := management.CreateProject(ctx, projects.CreateProject{ID: projectID, Name: "Stream authority"}) if err != nil { t.Fatal(err) } - reader, err := s.CreateProjectAPIKey(ctx, project.ID, uuid.NewString(), "reader") + reader, err := management.CreateAPIKey(ctx, projects.CreateAPIKey{ProjectID: project.ID, ID: uuid.NewString(), Name: "reader"}) if err != nil { t.Fatal(err) } - peer, err := s.CreateProjectAPIKey(ctx, project.ID, uuid.NewString(), "peer") + peer, err := management.CreateAPIKey(ctx, projects.CreateAPIKey{ProjectID: project.ID, ID: uuid.NewString(), Name: "peer"}) if err != nil { t.Fatal(err) } @@ -69,9 +71,9 @@ func TestLiveStreamClosesAfterKeyRevocationOrProjectArchive(t *testing.T) { // Keep an idle stream open across a successful authority recheck. time.Sleep(1100 * time.Millisecond) if archive { - _, err = s.ArchiveProject(ctx, project.ID) + _, err = management.ArchiveProject(ctx, projects.ArchiveProject{ID: project.ID}) } else { - err = s.RevokeProjectAPIKey(ctx, project.ID, reader.ID) + err = management.RevokeAPIKey(ctx, projects.RevokeAPIKey{ProjectID: project.ID, ID: reader.ID}) } if err != nil { t.Fatal(err)