Repository navigation
Description
What happened?
Summary
Deleting and recreating an atespace with the same name allows an in-flight CreateAtespaceAccessPolicy (or DeleteAtespace) request from the previous incarnation's owner to pass authorization against the old incarnation and commit onto the newly recreated atespace. As a result, a revoked former owner can regain owner on the new atespace incarnation—or delete the newly recreated atespace—without holding any grant on it.
Commit / Version
- Repository / Commit:
agent-substrate/substrate@ffed8551(main) - Environment: GKE
1.37.1-gke.2097000(Dataplane V2 enabled)
Steps to Reproduce
- Create a ServiceAccount
mallorywith no global grants and mint a bearer token forapi.ate-system.svc:kubectl create namespace config-personas kubectl create serviceaccount -n config-personas mallory kubectl create token -n config-personas mallory --audience api.ate-system.svc --duration 1h > /tmp/mallory.token - As a global/bootstrap owner (
admin), create atespaceconfig-race2and grantmalloryowneronconfig-race2:kubectl ate --token-file /tmp/admin.token create atespace config-race2 cat <<'EOF' | kubectl ate --token-file /tmp/admin.token create access-policy -a config-race2 -f - metadata: name: default bindings: - role: owner members: - user:system:serviceaccount:config-personas:mallory EOF
- Have
malloryconcurrently callCreateAtespaceAccessPolicyonconfig-race2grantingowner: [user:system:serviceaccount:config-personas:mallory, user:marker-mallory-<N>](while the old policy exists, these returnAlreadyExists):# In a loop (16–32 concurrent workers as mallory): kubectl ate --token-file /tmp/mallory.token create access-policy -a config-race2 -f mallory-policy.yaml - As
admin, deleteconfig-race2and immediately recreate it with no access policy:kubectl ate --token-file /tmp/admin.token delete atespace config-race2 kubectl ate --token-file /tmp/admin.token create atespace config-race2 kubectl ate --token-file /tmp/admin.token get access-policy -a config-race2 -o yaml
- Repeat with
mallorycallingDeleteAtespace(config-race2)instead ofCreateAtespaceAccessPolicy.
Expected Behavior
Per docs/authorization.md ("Managing access": "Deleting an atespace deletes its policy, so a recreated atespace starts with none") and Identity bug bash edge case 6:
mallory'sownergrant applies only to the old incarnation (uid: d7f5fa1d-...) ofconfig-race2.- Once
config-race2is deleted and recreated (uid: e21be427-...), it starts with no access policy (NotFound),malloryhas zero access to the new incarnation, andmallorycannot delete the new incarnation.
Actual Behavior
CreateAtespaceAccessPolicyrace: In 283 / 400 concurrent delete/recreate cycles (and 1 / 40 sequential delete-then-create cycles with 16 workers), the newly recreated atespace (uid: e21be427-cc50-4ced-bb01-e0de3e25be12) started life withmalloryasowner(written bymallory's request that was authorized against the old incarnationuid: d7f5fa1d-0d93-446d-b9e7-4efb2ee2ffe1).DeleteAtespacerace: In 37 / 150 cycles (and 8 / 30 in a second verification run),mallory'sDeleteAtespaceauthorized against the first incarnation deleted the freshly recreated second incarnation on whichmalloryhad no grant. The admin's own unconditionalDeleteAtespacealso intermittently failed withrpc error: code = Aborted desc = concurrent update conflict, please retry.
Root Cause
cmd/ateapi/internal/authz/interceptor.go:46-53evaluatescan_create_access_policy/can_deleteonatespace:<name>before invoking the gRPC handler and outside the PostgreSQL transaction.cmd/ateapi/internal/store/atepg/access_policy.go:120-141inserts intoatespace_access_policies(atespace_name, ...), whose foreign key (cmd/ateapi/internal/store/atepg/migrations/000001_initial.sql:212-219) referencesatespaces(name)rather thanatespaces(uid).- OpenFGA tuples (
atespace:<name>) are also keyed by name rather than UID. Whenmallory's request passes the interceptor check just beforeDeleteAtespacecommits, itsINSERT(orDELETE) executes afterCreateAtespacecommits the new row with the samename, bindingmallory's policy and OpenFGAownertuple to the new incarnation.
Logs / Evidence
VIOLATION iter 0: fresh incarnation e21be427-cc50-4ced-bb01-e0de3e25be12 (old d7f5fa1d-0d93-446d-b9e7-4efb2ee2ffe1) has policy [role:"owner" members:"user:system:serviceaccount:config-personas:mallory" members:"user:marker-mallory-28"] (err=<nil>) malOK=0->1
=== admin: get atespace
metadata:
createTime: "2026-10-26T21:09:33.924082430Z"
name: config-race2
uid: e21be427-cc50-4ced-bb01-e0de3e25be12
updateTime: "2026-10-26T21:09:33.924082430Z"
version: "1"
=== admin: get access-policy
bindings:
- members:
- user:system:serviceaccount:config-personas:mallory
- user:marker-mallory-28
role: owner
metadata:
createTime: "2026-10-26T21:09:33.924234971Z"
name: default
uid: 3d9d17a9-5fb0-4872-80f9-92d9f6692c36
updateTime: "2026-10-26T21:09:33.924234971Z"
version: "1"
=== mallory: get atespace
NAME AGE
config-race2 1s
=== psql: tuples + policy row
atespace:config-race2|owner|user:system%3Aserviceaccount%3Aconfig-personas%3Amallory|2026-10-26 21:09:33.936826+00
atespace:config-race2|owner|user:marker-mallory-28|2026-10-26 21:09:33.936826+00
config-race2|3d9d17a9-5fb0-4872-80f9-92d9f6692c36|1
config-race2|e21be427-cc50-4ced-bb01-e0de3e25be12
Sequential delete-then-create control run (16 workers, no overlapping admin RPCs):
VIOLATION iter 17: fresh incarnation 7c418a05-a94c-4167-880a-b41b1c349eb6 (old b86539cb-ffb4-49f5-b7df-6b75d2075c1d) has policy [role:"owner" members:"user:system:serviceaccount:config-personas:mallory" members:"user:marker-mallory-8"] (err=<nil>) malOK=0->1
done: mode=create 40 cycles, 1 violations, mallory OK=1
Expected Behavior
Expected Behavior
Per docs/authorization.md ("Managing access": "Deleting an atespace deletes its policy, so a recreated atespace starts with none") and Identity bug bash edge case 6:
mallory'sownergrant applies only to the old incarnation (uid: d7f5fa1d-...) ofconfig-race2.- Once
config-race2is deleted and recreated (uid: e21be427-...), it starts with no access policy (NotFound),malloryhas zero access to the new incarnation, andmallorycannot delete the new incarnation.
Steps to Reproduce
Steps to Reproduce
- Create a ServiceAccount
mallorywith no global grants and mint a bearer token forapi.ate-system.svc:kubectl create namespace config-personas kubectl create serviceaccount -n config-personas mallory kubectl create token -n config-personas mallory --audience api.ate-system.svc --duration 1h > /tmp/mallory.token - As a global/bootstrap owner (
admin), create atespaceconfig-race2and grantmalloryowneronconfig-race2:kubectl ate --token-file /tmp/admin.token create atespace config-race2 cat <<'EOF' | kubectl ate --token-file /tmp/admin.token create access-policy -a config-race2 -f - metadata: name: default bindings: - role: owner members: - user:system:serviceaccount:config-personas:mallory EOF
- Have
malloryconcurrently callCreateAtespaceAccessPolicyonconfig-race2grantingowner: [user:system:serviceaccount:config-personas:mallory, user:marker-mallory-<N>](while the old policy exists, these returnAlreadyExists):# In a loop (16–32 concurrent workers as mallory): kubectl ate --token-file /tmp/mallory.token create access-policy -a config-race2 -f mallory-policy.yaml - As
admin, deleteconfig-race2and immediately recreate it with no access policy:kubectl ate --token-file /tmp/admin.token delete atespace config-race2 kubectl ate --token-file /tmp/admin.token create atespace config-race2 kubectl ate --token-file /tmp/admin.token get access-policy -a config-race2 -o yaml
- Repeat with
mallorycallingDeleteAtespace(config-race2)instead ofCreateAtespaceAccessPolicy.
Sandbox Runtime
gVisor (runsc)
Agent Substrate Version / Commit SHA
Kubernetes Version & Environment
1.37.1-gke.2097000
Host OS & Architecture
Linux x86_64 (COS, running on GKE)
Relevant Logs and Diagnostic Output
### Logs / Evidence
VIOLATION iter 0: fresh incarnation e21be427-cc50-4ced-bb01-e0de3e25be12 (old d7f5fa1d-0d93-446d-b9e7-4efb2ee2ffe1) has policy [role:"owner" members:"user:system:serviceaccount:config-personas:mallory" members:"user:marker-mallory-28"] (err=<nil>) malOK=0->1
=== admin: get atespace
metadata:
createTime: "2026-10-26T21:09:33.924082430Z"
name: config-race2
uid: e21be427-cc50-4ced-bb01-e0de3e25be12
updateTime: "2026-10-26T21:09:33.924082430Z"
version: "1"
=== admin: get access-policy
bindings:
- members:
- user:system:serviceaccount:config-personas:mallory
- user:marker-mallory-28
role: owner
metadata:
createTime: "2026-10-26T21:09:33.924234971Z"
name: default
uid: 3d9d17a9-5fb0-4872-80f9-92d9f6692c36
updateTime: "2026-10-26T21:09:33.924234971Z"
version: "1"
=== mallory: get atespace
NAME AGE
config-race2 1s
=== psql: tuples + policy row
atespace:config-race2|owner|user:system%3Aserviceaccount%3Aconfig-personas%3Amallory|2026-10-26 21:09:33.936826+00
atespace:config-race2|owner|user:marker-mallory-28|2026-10-26 21:09:33.936826+00
config-race2|3d9d17a9-5fb0-4872-80f9-92d9f6692c36|1
config-race2|e21be427-cc50-4ced-bb01-e0de3e25be12
Sequential delete-then-create control run (16 workers, no overlapping admin RPCs):
VIOLATION iter 17: fresh incarnation 7c418a05-a94c-4167-880a-b41b1c349eb6 (old b86539cb-ffb4-49f5-b7df-6b75d2075c1d) has policy [role:"owner" members:"user:system:serviceaccount:config-personas:mallory" members:"user:marker-mallory-8"] (err=<nil>) malOK=0->1
done: mode=create 40 cycles, 1 violations, mallory OK=1Additional Context
No response
Confirmation
- I have searched existing issues and verified that this is not a duplicate.
- I have verified that this issue occurs on the latest commit on
main.
Activity
SHRUTI6991 commentedon Oct 26, 2026
It's unfortunate but our decision to use "name" instead of "uid" to store the resource results in known ABA problems. I think I will just document this as a known limitation since we anyway enforce min 26 characters and it's unlikely that the higher order systems would generate the same 26 char every single time.
cc: Taahir Ahmed (@ahmedtd) Julian Gutierrez Oschmann (@juli4n) Greg Castle (@destijl)
thompsonmax commentedon Oct 27, 2026
I haven't really dug too deep into either of these, but Shruti Nair (@SHRUTI6991) would either of these options resolve this?
Why Keying by
nameDoesn't Force an Unfixable ABA Problem HereTwo things are already true in
atepg:atespacestable already stores a per-incarnationuidcolumn (cmd/ateapi/internal/store/atepg/migrations/000001_initial.sql:17-22:name text PRIMARY KEY, uid text NOT NULL, ...).DeleteAtespace(cmd/ateapi/internal/store/atepg/atespace.go:121-152) already deletes theatespacesrow and its OpenFGA tuples atomically in a single PostgreSQL transactiontx(DELETE FROM atespacesat line 130 +DeleteAtespacePolicies(ctx, tx, name)at line 147 +tx.Commitat line 150).Because
atespaces(name)is aPRIMARY KEY, incarnation 2 (uid_2) can never exist inatespacesuntil the transaction that deleted incarnation 1 (uid_1) and wipeduid_1's OpenFGA tuples has already committed.The ABA race only happens today because
UnaryServerInterceptor(cmd/ateapi/internal/authz/interceptor.go:50-56) runsauthorizer.Checkoutside the handler's DB transaction without tying the write to theatespaceincarnation that existed whenCheckran.Option 1: Read
atespaces.uidBeforeauthorizer.Check, and Assert Thatuidin the Write Transaction (~20 lines, zero schema or OpenFGA changes)For any atespace-scoped mutation (
CreateAtespaceAccessPolicy,DeleteAtespace, etc.):authorizer.Check): QuerySELECT uid FROM atespaces WHERE name = $1->expectedUID.authorizer.Check(ctx, relation, "atespace:"+name)as today.tx): Assert that theatespacesrow still hasuid = expectedUID:DeleteAtespace(cmd/ateapi/internal/store/atepg/atespace.go:130-134):WHERE name = $1 AND ($2::text = '' OR uid = $2::text)already exists—we just populateprecondition.UID = expectedUIDwhen the caller didn't supply one.CreateAtespaceAccessPolicy(cmd/ateapi/internal/store/atepg/access_policy.go:120-143): Run:txbeforeINSERT INTO atespace_access_policiesandReconcileAtespaceBindings, returningAborted/NotFoundif zero rows match.Why this eliminates all ABA interleavings:
DeleteAtespace(uid_1)committed:expectedUIDisuid_1. Even if Step 2 (authorizer.Check) passes againstuid_1's tuples and the atespace is then deleted and recreated asuid_2, Step 3 checksWHERE name = $1 AND uid = 'uid_1', seesuid_2 != uid_1, and aborts without touchinguid_2.CreateAtespace(uid_2)committed:expectedUIDisuid_2. Becauseuid_2is already committed inatespaces,DeleteAtespace(uid_1)(and its atomic OpenFGA tuple wipe) must have already committed before Step 1 started — which means Step 2 (authorizer.Check, which starts after Step 1) is guaranteed to see 0 tuples for the old owner and reject withPermissionDenied.Option 2: Lock the
atespacesRow and Runauthorizer.CheckontxIn
cmd/ateapi/internal/authz/datastore.go:62-63,transactionalDatastorealready routesWrite,ReadPage, andReadAuthorizationModelthroughTxFromContext(ctx), and only leavesRead,ReadUserTuple, andReadUsersetTuples(used byfgaServer.Check) on the pool.If those three read methods also use
TxFromContext(ctx)when present (see also #3205), any mutation that locksSELECT 1 FROM atespaces WHERE name = $1 FOR NO KEY UPDATEintxand callsauthorizer.Check(ContextWithTx(ctx, tx), ...)is strictly serialized in PostgreSQL either beforeDeleteAtespace(onuid_1) or afterDeleteAtespacecommits (whenuid_1's tuples are already gone).SHRUTI6991 commentedon Oct 27, 2026
I don't like both the options.
Option 1 is reading UID from the store and passing that down to handler, that's additional check + we have to maintain some caching for it ourselves.
Option 2 is worse I feel, since now we need transactional read which might make parallel reads slower.
Option 3: document the limitation since we anyway enforce 26 bytes of characters and it's unlikely that the higher order systems would generate the same 26 char every single time.
ahmedtd commentedon Oct 28, 2026
I suspect this means we need to turn Postgres up to Serializable isolation? Otherwise I'm not sure how these two things that happen in transactions can conflict?
SHRUTI6991 commentedon Oct 29, 2026
The issue
ate-api-server handles an atespace-scoped write in two separate steps:
UnaryServerInterceptor(cmd/ateapi/internal/authz/interceptor.go) callsauthorizer.Check("can_create_access_policy", "atespace:config-race2"). OpenFGA reads the tuples straight from the connection pool, so this is its own autocommit transaction.atespace_name = 'config-race2'. The FK onatespace_access_policiesreferencesatespaces(name).Nothing ties step 2 to the atespace that step 1 approved. Both steps refer only to the name, and the name can point to a different atespace by the time step 2 runs.
Timeline
Result: the new atespace (
uid_2) starts with mallory as owner, though she was never granted anything on it.DeleteAtespacehas the same race. Mallory's delete is authorized againstuid_1but runsDELETE ... WHERE name = 'config-race2', so it deletesuid_2.The 26-character name rule doesn't cover this. The attacker doesn't guess a name. The trigger is an admin recreating the same name, which is normal for deterministic or GitOps-managed names.
Why Serializable won't fix it
Serializable guarantees the result is equivalent to some one-at-a-time order of the transactions. Postgres sees four separate transactions here:
Check(autocommit, on the pool)ownertuple foratespace:config-race2, finds itDeleteAtespaceCreateAtespaceconfig-race2rowtxRunning them one at a time as T1 → T2 → T3 → T4 gives exactly the bad outcome. That history is already serializable, so Postgres has nothing to detect and aborts nothing.
The bug is that the application treats "check, then write" as one operation while the database sees two. Isolation levels only protect what happens inside one transaction.
Short-term fix: pin the write to the UID that was checked
This needs no schema change and no OpenFGA change.
Check, runSELECT uid FROM atespaces WHERE name = $1, then putexpectedUIDin the context.DeleteAtespace: setprecondition.UID = expectedUIDwhen the caller left it empty. TheAND uid = $2clause already exists.SELECT 1 FROM atespaces WHERE name = $1 AND uid = $2 FOR SHAREbefore writing. ReturnAbortedif no row matches.Why it's correct:
expectedUID = uid_1. At t3 the row isuid_2, the assert fails, and the write aborts.expectedUID = uid_2. The delete at t1 already removed mallory's tuples in the same transaction, soCheckdenies.It costs one primary-key lookup per request, with nothing to cache.
Also in this change:
docs/authorization.mdthat other atespace-scoped writes are still affected until the long-term fix.Long-term fix: key atespace identity by UID
Make the race structurally impossible for every atespace-scoped RPC:
atespace:<uid>instead ofatespace:<name>.atespace_access_policies, actors, and the rest referenceatespaces(uid)withON DELETE CASCADE.Checkagainstatespace:<uid>, and pass the UID to the handler. Store writes take the UID.uid_1then fails on the FK, because that row is gone.uid_2has no tuples for anyone the admin didn't grant.We're pre-v1.0.0, so this can change the schema and tuples directly, with no migration or compatibility code. It touches every atespace-scoped table and RPC, so it should get its own issue and design discussion.
SHRUTI6991 commentedon Oct 29, 2026
Synched offline. We are documenting this as a known limitation.
docs: Document that atespace names must not be reused (agent-substrat…
docs: Document that atespace names must not be reused (agent-substrat…
docs: Document that atespace names must not be reused (#3450)