Skip to content

Commit 3283347

Browse files
authored
feat: isolate managed-agent resources by workspace (#144)
## What changed - Introduce Workspace as Mango's sole tenant and authorization boundary without changing CMA request/response shapes. - Authenticate opaque API keys at the HTTP edge and attach exactly one Workspace scope; all keys in a Workspace have equal access. - Scope Agents, Environments, Sessions, Files, Skills, Memory Stores, Vaults, Deployments, child resources, lists, mutations, and S3 object keys. - Recover and propagate Workspace scope across scheduled Deployment, Temporal activity, NATS, lifecycle, and reconciliation paths. - Add local operator commands for Workspace and API-key lifecycle. No user model, role hierarchy, per-resource ACL, OpenFGA, or administration HTTP/Console surface is introduced. - Keep pre-tenancy data in `wrkspc_default` and preserve stored legacy object keys for cleanup. - Make system-store tenant dependency reads and writes fail closed without an explicit Workspace. Single-tenant embedding/tests use the explicitly named default-Workspace store. ## Verification - `go test ./...` - `go test -race ./...` - `go vet ./...` - `make lint` — 0 issues - `make security` — no reachable Go vulnerabilities; npm audit passed with the existing explicitly accepted upstream `image-size` build advisories - `make test-service` — real PostgreSQL, Temporal, NATS, MinIO, and sandbox suites passed - website typecheck and production build - Compose configuration validation - PostgreSQL isolation tests cover top-level reads/lists, child access, API-key sharing/revocation, cross-Workspace Deployment dependencies, fail-closed system access, and validated composite constraints ## Compatibility Workspace identity stays credential-derived and is not added to CMA JSON. Existing SDK/wire semantics, durable Session snapshots, provider-native web search/fetch behavior, and the v1.63.1 SDK surface remain unchanged. The server accepts the documented `x-api-key` form and the documented OpenAPI Bearer alternative; sending both is rejected. Health, readiness, and the embedded OpenAPI document remain public. ## Durability and security Migration `00032_workspaces.sql` creates Workspaces and digest-only API keys, backfills every existing root into `wrkspc_default`, adds non-null ownership and indexes, and installs then validates the cross-Workspace Deployment constraints. Session Agent/Environment fields intentionally remain durable snapshots; public admission locks their same-Workspace dependencies. Public handlers use a scoped Store. Privileged reconcilers can enumerate global work, but tenant dependency reads and tenant writes require recovered scope and never silently fall back to the default Workspace. Object-store keys are Workspace-prefixed while persisted legacy keys remain directly cleanable. `MANAGED_AGENT_DATABASE_URL` remains an operator/root credential. Workspace/API-key administration is deliberately local CLI-to-PostgreSQL only and is not exposed by the HTTP server. ## Local Claude Code review Review was run locally with Claude Code 2.1.234, Opus (`claude-opus-4-7`), `--permission-mode bypassPermissions`, `--bare`, and slash commands disabled. The review inspected `origin/main..HEAD` and produced 20 findings. Each was independently checked against the actual call graph: | # | Claude finding | Disposition | |---|---|---| | 1 | Bearer auth should be removed | Not changed: Bearer is an explicit documented/OpenAPI alternative, both-header requests are rejected, and credentials are opaque in either transport. | | 2 | Unscoped system Agent reads silently select `wrkspc_default` | Fixed: system tenant dependency reads now return `ErrMissingScope`; explicit scope is tested. | | 3 | Environment `Put` changed from upsert to insert-only | Intentional: `Put` is Create-only, mutable updates use `Update`, and insert-only prevents a global-ID collision from overwriting another Workspace. | | 4 | Session Resource/File ownership needs another Workspace FK | Not actionable: API admission creates the scoped File and child atomically, IDs are globally unique, parent access is asserted, and reconciliation uses the persisted BlobKey. A direct corrupt foreign reference fails scoped File lookup rather than deleting another tenant's blob. | | 5 | System writes can silently land in `wrkspc_default` | Fixed: `NewSystemStore` writes fail closed without scope; `NewDefaultWorkspaceStore` makes single-tenant intent explicit. Full service tests verify async scope recovery. | | 6 | Local key administration has no independent admin policy | Intentional boundary: there is no admin HTTP route; CLI access requires the PostgreSQL root credential and is documented as operator access. | | 7 | Restart reasserts a revoked bootstrap key | Intentional desired-state behavior: while `MANAGED_AGENT_API_KEY` remains configured, startup rotates/reasserts the fixed bootstrap credential. Operators remove the env setting to stop managing it this way. | | 8 | `NOT VALID` constraints are not validated; add Session dependency FKs | Partially fixed: all three installed composite constraints are now validated in the migration and tested. Agent/Environment Session fields remain snapshots by documented durable-admission design. | | 9 | Prepared Session File transition ignores zero affected rows | Already protected: `insertPreparedSessionResources` checks `RowsAffected() == 1` and aborts the transaction otherwise. | | 10 | Session ownership assertions add a database round-trip | Deferred performance idea; correctness/security is fail-closed and this PR prioritizes a small auditable boundary. | | 11 | Legacy File BlobKeys are orphaned | False positive: a persisted legacy `files/... ` key is deleted directly; fallback reconstruction is used only when the File row/key is already absent. | | 12 | Legacy Skill BlobKeys are not cleanable | False positive: Skill cleanup always uses the BlobKey persisted on the version, including legacy unprefixed keys. | | 13 | Optional-scope SQL may reduce index use | Deferred low-risk optimization; HTTP list paths build direct `workspace_id = ...` predicates and service tests passed. | | 14 | File delete may disclose foreign reference state | False positive under current schema/call paths; the review itself notes the required cross-Workspace state is impossible, and foreign IDs return not found. | | 15 | Active-key count could be exposed later | Not actionable: it is called only during process startup and no HTTP route exposes it. | | 16 | `last_used_at` is not updated | Non-blocking follow-up candidate for operator observability; it does not affect authentication, revocation, or isolation and avoiding per-request writes is preferable here. | | 17 | Internal prepared BlobKey could carry another prefix | Not actionable at the public boundary: keys are generated internally from the authenticated context before the scoped DB insert; legacy keys also preclude a blanket DB prefix check. | | 18 | A corrupt Deployment Workspace could mis-scope reconciliation | Addressed by validated non-null Workspace and composite dependency constraints; claims propagate the stored authoritative Workspace. | | 19 | Boot-time File reconciliation could race serving | False positive: reconciliation completes before the HTTP server starts, as the review also observed. | | 20 | Workspace might leak into durable JSON later | Current structs use `json:"-"` and SDK/wire golden tests guard shape; hypothetical future fields are outside this change. | The two accepted security findings (2/5, one root cause) and the valid portion of finding 8 were fixed before this PR was opened. All verification above was rerun afterward. ## Checklist - [x] Tests cover the changed behavior. - [x] Public behavior is documented. - [x] Compatibility claims are limited to the existing pinned SDK/wire surface. - [x] No credentials, generated build output, or local databases are included.
1 parent 285195c commit 3283347

64 files changed

Lines changed: 2361 additions & 364 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ production-oriented architecture is built in Go on PostgreSQL and Temporal.
4040
client-action waits survive API and worker restarts.
4141
- **Bring your own execution environment.** Choose local, Docker, E2B,
4242
CubeSandbox, OpenSandbox, or Daytona sandbox adapters.
43-
- **Run the whole stack locally.** Start a credential-free development stack
44-
with an offline model, PostgreSQL, Temporal, NATS, and MinIO.
43+
- **Run the whole stack locally.** Start without external model credentials;
44+
the Compose stack supplies a development-only Mango API key.
4545
- **Inspect every turn.** Query the persisted event history, stream live
4646
previews over SSE, and inspect active workflows in Temporal UI.
4747

@@ -62,7 +62,9 @@ Verify that Mango is ready:
6262
curl -i http://localhost:8080/readyz
6363
```
6464

65-
The local stack uses a deterministic offline model, so no API key is required.
65+
The local stack uses a deterministic offline model, so no model API key is
66+
required. Protected Mango routes use the development key
67+
`sk-mango-local-development`; health and readiness remain public.
6668
Follow the [five-minute walkthrough](https://yanpgwang.github.io/managed-agent-go/getting-started)
6769
to create an Environment, Agent, and Session, then send and stream your first
6870
message.

SECURITY.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,16 @@ maintainer contact without disclosing vulnerability details.
2929
- The Docker provider gives container isolation and disables networking by
3030
default, but containers share the host kernel and the provider has not been
3131
audited for hostile multi-tenant workloads.
32-
- `-strict` checks that authentication headers are present; it does not
33-
validate credentials or implement authorization.
32+
- Every protected API request is authenticated by an opaque API key and scoped
33+
to one Workspace. Top-level resources, child resources, scheduled work, and
34+
object-store keys are isolated by that Workspace. Health, readiness, and the
35+
embedded OpenAPI document remain public.
36+
- All keys for one Workspace have identical access to that Workspace. Mango
37+
does not model end users, roles, per-resource grants, or user-level audit
38+
identity; a SaaS or enterprise control plane must own those concerns and
39+
issue or revoke Workspace keys.
40+
- `-strict` additionally validates CMA version, beta, and content-type headers;
41+
it does not change authorization semantics.
3442
- PostgreSQL journals tool attempts, but an external side effect can still be
3543
ambiguous if execution succeeds and its durable result is lost. Exactly-once
3644
behavior requires idempotency from the external system.

cmd/managed-agent/main.go

Lines changed: 157 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -391,30 +391,155 @@ func newHTTPServer(addr string, handler http.Handler) *http.Server {
391391

392392
func main() {
393393
if len(os.Args) < 2 {
394-
log.Fatal("usage: managed-agent <serve|orchestrate> [flags]")
394+
log.Fatal("usage: managed-agent <serve|orchestrate|workspace|api-key> [flags]")
395395
}
396396
switch os.Args[1] {
397397
case "serve":
398398
runServe()
399399
case "orchestrate":
400400
runOrchestrate()
401+
case "workspace":
402+
runWorkspaceCommand()
403+
case "api-key":
404+
runAPIKeyCommand()
401405
default:
402-
log.Fatal("usage: managed-agent <serve|orchestrate> [flags]")
406+
log.Fatal("usage: managed-agent <serve|orchestrate|workspace|api-key> [flags]")
403407
}
404408
}
405409

406410
func runServe() {
407411
fs := flag.NewFlagSet("serve", flag.ExitOnError)
408412
addr := fs.String("addr", defaultAddr, "listen address (default binds to loopback; use e.g. :8080 to expose on all interfaces)")
409-
strict := fs.Bool("strict", false, "require Claude API wire headers (auth, version, beta, content-type) to be present and valid; this is header validation, NOT authentication")
413+
strict := fs.Bool("strict", false, "require Claude API version, beta, and content-type headers; API-key authentication is always enforced")
410414
_ = fs.Parse(os.Args[2:])
411415

412416
cfg := httpapi.Config{
413-
RequireBeta: *strict, RequireAuth: *strict, RequireVersion: *strict, RequireContentType: *strict,
417+
RequireBeta: *strict, RequireVersion: *strict, RequireContentType: *strict,
414418
}
415419
runPostgresAPI(*addr, cfg)
416420
}
417421

422+
func runWorkspaceCommand() {
423+
if len(os.Args) < 3 {
424+
log.Fatal("usage: managed-agent workspace <create|list> [flags]")
425+
}
426+
switch os.Args[2] {
427+
case "create":
428+
fs := flag.NewFlagSet("workspace create", flag.ExitOnError)
429+
name := fs.String("name", "", "workspace display name")
430+
_ = fs.Parse(os.Args[3:])
431+
if err := withOperatorStore(func(ctx context.Context, store *pg.Store) error {
432+
item, err := store.CreateWorkspace(ctx, *name)
433+
if err != nil {
434+
return err
435+
}
436+
fmt.Printf("%s\t%s\n", item.ID, item.Name)
437+
return nil
438+
}); err != nil {
439+
log.Fatalf("workspace create: %v", err)
440+
}
441+
case "list":
442+
fs := flag.NewFlagSet("workspace list", flag.ExitOnError)
443+
_ = fs.Parse(os.Args[3:])
444+
if err := withOperatorStore(func(ctx context.Context, store *pg.Store) error {
445+
items, err := store.ListWorkspaces(ctx)
446+
if err != nil {
447+
return err
448+
}
449+
for _, item := range items {
450+
fmt.Printf("%s\t%s\t%s\n", item.ID, item.Name, item.CreatedAt.Format(time.RFC3339))
451+
}
452+
return nil
453+
}); err != nil {
454+
log.Fatalf("workspace list: %v", err)
455+
}
456+
default:
457+
log.Fatal("usage: managed-agent workspace <create|list> [flags]")
458+
}
459+
}
460+
461+
func runAPIKeyCommand() {
462+
if len(os.Args) < 3 {
463+
log.Fatal("usage: managed-agent api-key <create|list|revoke> [flags]")
464+
}
465+
switch os.Args[2] {
466+
case "create":
467+
fs := flag.NewFlagSet("api-key create", flag.ExitOnError)
468+
workspaceID := fs.String("workspace", "", "workspace ID")
469+
label := fs.String("label", "", "operator-visible key label")
470+
_ = fs.Parse(os.Args[3:])
471+
if strings.TrimSpace(*workspaceID) == "" {
472+
log.Fatal("api-key create: -workspace is required")
473+
}
474+
if err := withOperatorStore(func(ctx context.Context, store *pg.Store) error {
475+
item, secret, err := store.CreateAPIKey(ctx, *workspaceID, *label)
476+
if err != nil {
477+
return err
478+
}
479+
// The plaintext secret is intentionally emitted only at creation.
480+
fmt.Printf("id\t%s\nworkspace\t%s\napi_key\t%s\n", item.ID, item.WorkspaceID, secret)
481+
return nil
482+
}); err != nil {
483+
log.Fatalf("api-key create: %v", err)
484+
}
485+
case "list":
486+
fs := flag.NewFlagSet("api-key list", flag.ExitOnError)
487+
workspaceID := fs.String("workspace", "", "workspace ID")
488+
_ = fs.Parse(os.Args[3:])
489+
if strings.TrimSpace(*workspaceID) == "" {
490+
log.Fatal("api-key list: -workspace is required")
491+
}
492+
if err := withOperatorStore(func(ctx context.Context, store *pg.Store) error {
493+
items, err := store.ListAPIKeys(ctx, *workspaceID)
494+
if err != nil {
495+
return err
496+
}
497+
for _, item := range items {
498+
status := "active"
499+
if item.RevokedAt != nil {
500+
status = "revoked"
501+
}
502+
fmt.Printf("%s\t%s\t%s\t%s\n", item.ID, item.Label, status, item.CreatedAt.Format(time.RFC3339))
503+
}
504+
return nil
505+
}); err != nil {
506+
log.Fatalf("api-key list: %v", err)
507+
}
508+
case "revoke":
509+
fs := flag.NewFlagSet("api-key revoke", flag.ExitOnError)
510+
id := fs.String("id", "", "API key ID")
511+
_ = fs.Parse(os.Args[3:])
512+
if strings.TrimSpace(*id) == "" {
513+
log.Fatal("api-key revoke: -id is required")
514+
}
515+
if err := withOperatorStore(func(ctx context.Context, store *pg.Store) error {
516+
return store.RevokeAPIKey(ctx, *id)
517+
}); err != nil {
518+
log.Fatalf("api-key revoke: %v", err)
519+
}
520+
fmt.Printf("revoked\t%s\n", *id)
521+
default:
522+
log.Fatal("usage: managed-agent api-key <create|list|revoke> [flags]")
523+
}
524+
}
525+
526+
func withOperatorStore(run func(context.Context, *pg.Store) error) error {
527+
databaseURL := strings.TrimSpace(os.Getenv(envDatabaseURL))
528+
if databaseURL == "" {
529+
return fmt.Errorf("%s is required", envDatabaseURL)
530+
}
531+
ctx := context.Background()
532+
pool, err := pg.Pool(ctx, databaseURL)
533+
if err != nil {
534+
return fmt.Errorf("postgres: %w", err)
535+
}
536+
defer pool.Close()
537+
if err := pg.Migrate(ctx, pool); err != nil {
538+
return fmt.Errorf("migrate: %w", err)
539+
}
540+
return run(ctx, pg.NewSystemStore(pool, domain.NewRandomIDGen(), realClock{}))
541+
}
542+
418543
func runPostgresAPI(addr string, cfg httpapi.Config) {
419544
databaseURL := os.Getenv(envDatabaseURL)
420545
if databaseURL == "" {
@@ -433,6 +558,20 @@ func runPostgresAPI(addr string, cfg httpapi.Config) {
433558
ids := domain.NewRandomIDGen()
434559
clock := realClock{}
435560
pgStore := pg.NewStore(pool, ids, clock)
561+
if bootstrapKey := strings.TrimSpace(os.Getenv(envAPIKey)); bootstrapKey != "" {
562+
if err := pgStore.BootstrapAPIKey(ctx, bootstrapKey); err != nil {
563+
log.Fatalf("serve: bootstrap API key: %v", err)
564+
}
565+
}
566+
keyCount, err := pgStore.CountActiveAPIKeys(ctx)
567+
if err != nil {
568+
log.Fatalf("serve: count API keys: %v", err)
569+
}
570+
if keyCount == 0 {
571+
log.Fatalf("serve: no active API key; set %s or run managed-agent api-key create", envAPIKey)
572+
}
573+
cfg.Authenticator = pgStore
574+
systemStore := pg.NewSystemStore(pool, ids, clock)
436575
memory := app.NewMemoryService(pg.NewMemoryRepository(pgStore), ids, clock)
437576
vaults, err := resolveVaultService(pgStore, ids, clock)
438577
if err != nil {
@@ -468,14 +607,22 @@ func runPostgresAPI(addr string, cfg httpapi.Config) {
468607
LimitedNetwork: providerCapabilities.LimitedNetwork,
469608
},
470609
)
471-
fileRuntime, err := resolveFiles(ctx, pgStore, ids, clock, true)
610+
fileRuntime, err := resolveFiles(ctx, pgStore, ids, clock, false)
472611
if err != nil {
473612
log.Printf("serve: Files API disabled: %v", err)
474613
fileRuntime = nil
475614
} else if fileRuntime == nil {
476615
log.Printf("serve: Files API disabled; %s is not configured", fileS3BucketEnv)
477616
} else {
478-
log.Printf("serve: Files API object store connected and reconciled")
617+
fileReconciler := app.NewFileService(
618+
pg.NewFileRepository(systemStore), fileRuntime.blobs, ids, clock,
619+
)
620+
if err := fileReconciler.Reconcile(ctx); err != nil {
621+
log.Printf("serve: Files API disabled: reconcile incomplete operations: %v", err)
622+
fileRuntime = nil
623+
} else {
624+
log.Printf("serve: Files API object store connected and reconciled")
625+
}
479626
}
480627
var files *app.FileService
481628
var skills *app.SkillService
@@ -486,7 +633,10 @@ func runPostgresAPI(addr string, cfg httpapi.Config) {
486633
skills = app.NewSkillService(
487634
pg.NewSkillRepository(pgStore), fileRuntime.blobs, ids, clock,
488635
)
489-
if err := skills.Reconcile(ctx); err != nil {
636+
skillReconciler := app.NewSkillService(
637+
pg.NewSkillRepository(systemStore), fileRuntime.blobs, ids, clock,
638+
)
639+
if err := skillReconciler.Reconcile(ctx); err != nil {
490640
log.Printf("serve: Skills API disabled: reconcile incomplete operations: %v", err)
491641
skills = nil
492642
} else {

cmd/managed-agent/orchestrate.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ const (
321321
envTemporalHostPort = "MANAGED_AGENT_TEMPORAL_HOSTPORT"
322322
envTemporalNamespace = "MANAGED_AGENT_TEMPORAL_NAMESPACE"
323323
envNATSURL = "MANAGED_AGENT_NATS_URL"
324+
envAPIKey = "MANAGED_AGENT_API_KEY"
324325
)
325326

326327
// runOrchestrate boots the Temporal execution role: it runs PostgreSQL
@@ -346,7 +347,7 @@ func runOrchestrate() {
346347
log.Printf("orchestrate: postgres connected and migrated")
347348

348349
ids := domain.NewRandomIDGen()
349-
store := pg.NewStore(pool, ids, realClock{})
350+
store := pg.NewSystemStore(pool, ids, realClock{})
350351
vaults, err := resolveVaultService(store, ids, realClock{})
351352
if err != nil {
352353
log.Fatalf("orchestrate: Vault runtime keyring: %v", err)

deployments/local/README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ When `~/.config/mango/dev.env` exists, `make local-up` loads it via
3131
worker uses the real Messages endpoint. A missing file or empty model values
3232
keep the offline deterministic model.
3333

34+
The API bootstraps `sk-mango-local-development` for the default Workspace.
35+
Override it with `MANAGED_AGENT_API_KEY` before `make local-up`; never reuse the
36+
bundled value outside local development.
37+
3438
`make health` returns only once Postgres accepts connections, the Temporal
3539
frontend answers `cluster health`, NATS `/healthz` is green, MinIO answers its
3640
live probe, the API answers `/readyz`, and the worker process is alive.
@@ -47,6 +51,7 @@ docker compose -f deployments/local/compose.yaml ps
4751
```sh
4852
# Application database (pgx / goose / sqlc)
4953
export MANAGED_AGENT_DATABASE_URL="postgres://postgres:postgres@localhost:5432/managed_agent?sslmode=disable"
54+
export MANAGED_AGENT_API_KEY="sk-mango-local-development"
5055

5156
# Temporal frontend (Go SDK client)
5257
export MANAGED_AGENT_TEMPORAL_HOSTPORT="localhost:7233"
@@ -110,7 +115,7 @@ make local-down VOLUMES=1 # also delete the Postgres and MinIO volumes
110115

111116
This stack is for local development and integration tests only. It already
112117
keeps API and worker process roles separate, but it is not a production
113-
deployment manifest: authentication, TLS, secrets, rolling worker versioning,
118+
deployment manifest: end-user authorization, TLS, secrets, rolling worker versioning,
114119
managed persistence, observability, resource limits, and production object
115120
storage remain deployment work. The bundled MinIO credentials and deterministic
116121
Vault keyring are not a production recommendation. Files startup reconciliation

deployments/local/compose.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ services:
125125
condition: service_healthy
126126
environment:
127127
MANAGED_AGENT_DATABASE_URL: postgres://postgres:postgres@postgres:5432/managed_agent?sslmode=disable
128+
MANAGED_AGENT_API_KEY: ${MANAGED_AGENT_API_KEY:-sk-mango-local-development}
128129
MANAGED_AGENT_TEMPORAL_HOSTPORT: temporal:7233
129130
MANAGED_AGENT_TEMPORAL_NAMESPACE: default
130131
MANAGED_AGENT_NATS_URL: nats://nats:4222

docs/api/environment-work.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,10 @@ object store are configured.
5959
## Security boundary
6060

6161
The official worker sends an Environment key as a bearer credential for Work,
62-
Session, event, and Skill requests. Mango strict mode currently checks only
63-
that an API key or bearer header is present; it does not issue Environment keys
64-
or scope them to one Environment. Work `secret` is therefore returned as
65-
`null`. Put authentication, tenant isolation, and environment-scoped
66-
authorization in front of this surface before production exposure.
62+
Session, event, and Skill requests. Mango authenticates that credential as a
63+
Workspace API key and limits all of those resources to the same Workspace. It
64+
does not issue narrower Environment-worker credentials, so Work `secret`
65+
remains `null`. A surrounding control plane should add Environment-specific
66+
policy before exposing this surface to untrusted workers.
6767

6868
See [API compatibility](../compatibility.md) for the current support boundary.

docs/api/overview.md

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,12 @@ Resource-specific request shapes are covered in:
5454

5555
## Headers
5656

57-
The default development server accepts requests without compatibility headers.
58-
Run with `-strict` to require them:
57+
Every protected route requires an API key. The default development stack uses
58+
`sk-mango-local-development`. Run with `-strict` to additionally require the
59+
CMA compatibility headers:
5960

6061
```http
61-
x-api-key: any-non-empty-value
62+
x-api-key: sk-mango-local-development
6263
anthropic-version: 2023-06-01
6364
anthropic-beta: managed-agents-2026-04-01
6465
content-type: application/json
@@ -79,8 +80,21 @@ continues to use the Managed Agents beta when attaching a Memory Store.
7980
Dreams require the separate `dreaming-2026-04-21` preview upstream. Mango does
8081
not currently serve `/v1/dreams` or claim the v1.63.1 `output_behavior` union.
8182

82-
`authorization` may replace `x-api-key`. Strict mode currently validates header
83-
presence and version/beta values; it is not a production authentication system.
83+
`authorization: Bearer <key>` may replace `x-api-key`, but sending both is an
84+
authentication error. Each key resolves to exactly one Workspace, and every
85+
key for that Workspace can access the same resources. Workspace IDs are not
86+
added to CMA request or response bodies.
87+
88+
Mango intentionally has no end-user or role model. A surrounding SaaS may map
89+
many users to a Workspace and apply its own RBAC before calling Mango. Use the
90+
operator CLI to manage the OSS boundary:
91+
92+
```sh
93+
managed-agent workspace create -name acme
94+
managed-agent api-key create -workspace wrkspc_... -label production
95+
managed-agent api-key list -workspace wrkspc_...
96+
managed-agent api-key revoke -id key_...
97+
```
8498

8599
Every response includes a `request-id` header. JSON request bodies are limited
86100
to 32 MiB and unknown top-level fields are rejected. A file upload is limited

docs/architecture.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ Provider sandbox bindings and File/Skill lifecycle intents are persisted in
1717
PostgreSQL; File bytes and immutable custom Skill archives live in object
1818
storage when those optional surfaces are enabled.
1919

20+
The HTTP edge authenticates opaque API keys into a single Workspace tenant
21+
scope. PostgreSQL roots, asynchronous execution, and object keys preserve that
22+
scope; end-user identity and enterprise RBAC remain outside Mango. See
23+
[Workspace tenancy](architecture/workspace-tenancy.md).
24+
2025
```mermaid
2126
flowchart LR
2227
Client["Managed Agents client"] --> API["HTTP API"]
@@ -188,8 +193,9 @@ The strongest current risks are semantic rather than structural:
188193
create-before-binding crash window and workers autonomously resume fenced
189194
deletions. Provider-aware routing for heterogeneous workers, quotas, and
190195
eviction are not implemented.
191-
3. Worker Versioning, observability, authentication, large-payload offload, and
192-
production manifests remain open.
196+
3. Worker Versioning, observability, enterprise identity/RBAC, large-payload
197+
offload, and production manifests remain open. The OSS Workspace API-key
198+
and tenant-isolation boundary is implemented.
193199
4. Provider Transcript, native Web Search/Fetch, sandbox result
194200
materialization, and unauthenticated MCP tools are implemented. Context
195201
Snapshots, provider-round records, deployment-managed MCP authentication, and

0 commit comments

Comments
 (0)