Skip to content

[bug-bash] Deleted-and-recreated atespace can inherit its previous owner (or be deleted by its previous owner) #3255

Open

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

  1. Create a ServiceAccount mallory with no global grants and mint a bearer token for api.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
  2. As a global/bootstrap owner (admin), create atespace config-race2 and grant mallory owner on config-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
  3. Have mallory concurrently call CreateAtespaceAccessPolicy on config-race2 granting owner: [user:system:serviceaccount:config-personas:mallory, user:marker-mallory-<N>] (while the old policy exists, these return AlreadyExists):
    # 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
  4. As admin, delete config-race2 and 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
  5. Repeat with mallory calling DeleteAtespace(config-race2) instead of CreateAtespaceAccessPolicy.

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's owner grant applies only to the old incarnation (uid: d7f5fa1d-...) of config-race2.
  • Once config-race2 is deleted and recreated (uid: e21be427-...), it starts with no access policy (NotFound), mallory has zero access to the new incarnation, and mallory cannot delete the new incarnation.

Actual Behavior

  • CreateAtespaceAccessPolicy race: 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 with mallory as owner (written by mallory's request that was authorized against the old incarnation uid: d7f5fa1d-0d93-446d-b9e7-4efb2ee2ffe1).
  • DeleteAtespace race: In 37 / 150 cycles (and 8 / 30 in a second verification run), mallory's DeleteAtespace authorized against the first incarnation deleted the freshly recreated second incarnation on which mallory had no grant. The admin's own unconditional DeleteAtespace also intermittently failed with rpc error: code = Aborted desc = concurrent update conflict, please retry.

Root Cause

  1. cmd/ateapi/internal/authz/interceptor.go:46-53 evaluates can_create_access_policy / can_delete on atespace:<name> before invoking the gRPC handler and outside the PostgreSQL transaction.
  2. cmd/ateapi/internal/store/atepg/access_policy.go:120-141 inserts into atespace_access_policies(atespace_name, ...), whose foreign key (cmd/ateapi/internal/store/atepg/migrations/000001_initial.sql:212-219) references atespaces(name) rather than atespaces(uid).
  3. OpenFGA tuples (atespace:<name>) are also keyed by name rather than UID. When mallory's request passes the interceptor check just before DeleteAtespace commits, its INSERT (or DELETE) executes after CreateAtespace commits the new row with the same name, binding mallory's policy and OpenFGA owner tuple 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's owner grant applies only to the old incarnation (uid: d7f5fa1d-...) of config-race2.
  • Once config-race2 is deleted and recreated (uid: e21be427-...), it starts with no access policy (NotFound), mallory has zero access to the new incarnation, and mallory cannot delete the new incarnation.

Steps to Reproduce

Steps to Reproduce

  1. Create a ServiceAccount mallory with no global grants and mint a bearer token for api.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
  2. As a global/bootstrap owner (admin), create atespace config-race2 and grant mallory owner on config-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
  3. Have mallory concurrently call CreateAtespaceAccessPolicy on config-race2 granting owner: [user:system:serviceaccount:config-personas:mallory, user:marker-mallory-<N>] (while the old policy exists, these return AlreadyExists):
    # 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
  4. As admin, delete config-race2 and 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
  5. Repeat with mallory calling DeleteAtespace(config-race2) instead of CreateAtespaceAccessPolicy.

Sandbox Runtime

gVisor (runsc)

Agent Substrate Version / Commit SHA

ffed855

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=1

Additional 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

  1. SHRUTI6991 commented on Oct 26, 2026

    @SHRUTI6991
    Collaborator

    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)

  2. thompsonmax commented on Oct 27, 2026

    @thompsonmax
    CollaboratorAuthor

    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 name Doesn't Force an Unfixable ABA Problem Here

    Two things are already true in atepg:

    1. The atespaces table already stores a per-incarnation uid column (cmd/ateapi/internal/store/atepg/migrations/000001_initial.sql:17-22: name text PRIMARY KEY, uid text NOT NULL, ...).
    2. DeleteAtespace (cmd/ateapi/internal/store/atepg/atespace.go:121-152) already deletes the atespaces row and its OpenFGA tuples atomically in a single PostgreSQL transaction tx (DELETE FROM atespaces at line 130 + DeleteAtespacePolicies(ctx, tx, name) at line 147 + tx.Commit at line 150).
      Because atespaces(name) is a PRIMARY KEY, incarnation 2 (uid_2) can never exist in atespaces until the transaction that deleted incarnation 1 (uid_1) and wiped uid_1's OpenFGA tuples has already committed.
      The ABA race only happens today because UnaryServerInterceptor (cmd/ateapi/internal/authz/interceptor.go:50-56) runs authorizer.Check outside the handler's DB transaction without tying the write to the atespace incarnation that existed when Check ran.

    Option 1: Read atespaces.uid Before authorizer.Check, and Assert That uid in the Write Transaction (~20 lines, zero schema or OpenFGA changes)

    For any atespace-scoped mutation (CreateAtespaceAccessPolicy, DeleteAtespace, etc.):

    1. Step 1 (Before authorizer.Check): Query SELECT uid FROM atespaces WHERE name = $1 -> expectedUID.
    2. Step 2: Run authorizer.Check(ctx, relation, "atespace:"+name) as today.
    3. Step 3 (Inside the write tx): Assert that the atespaces row still has uid = expectedUID:
      • In DeleteAtespace (cmd/ateapi/internal/store/atepg/atespace.go:130-134): WHERE name = $1 AND ($2::text = '' OR uid = $2::text) already exists—we just populate precondition.UID = expectedUID when the caller didn't supply one.
      • In CreateAtespaceAccessPolicy (cmd/ateapi/internal/store/atepg/access_policy.go:120-143): Run:
        SELECT 1 FROM atespaces WHERE name = $1 AND uid = $2 FOR NO KEY UPDATE
        inside tx before INSERT INTO atespace_access_policies and ReconcileAtespaceBindings, returning Aborted / NotFound if zero rows match.
        Why this eliminates all ABA interleavings:
    • If Step 1 ran before DeleteAtespace(uid_1) committed: expectedUID is uid_1. Even if Step 2 (authorizer.Check) passes against uid_1's tuples and the atespace is then deleted and recreated as uid_2, Step 3 checks WHERE name = $1 AND uid = 'uid_1', sees uid_2 != uid_1, and aborts without touching uid_2.
    • If Step 1 ran after CreateAtespace(uid_2) committed: expectedUID is uid_2. Because uid_2 is already committed in atespaces, 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 with PermissionDenied.

    Option 2: Lock the atespaces Row and Run authorizer.Check on tx

    In cmd/ateapi/internal/authz/datastore.go:62-63, transactionalDatastore already routes Write, ReadPage, and ReadAuthorizationModel through TxFromContext(ctx), and only leaves Read, ReadUserTuple, and ReadUsersetTuples (used by fgaServer.Check) on the pool.
    If those three read methods also use TxFromContext(ctx) when present (see also #3205), any mutation that locks SELECT 1 FROM atespaces WHERE name = $1 FOR NO KEY UPDATE in tx and calls authorizer.Check(ContextWithTx(ctx, tx), ...) is strictly serialized in PostgreSQL either before DeleteAtespace (on uid_1) or after DeleteAtespace commits (when uid_1's tuples are already gone).

  3. SHRUTI6991 commented on Oct 27, 2026

    @SHRUTI6991
    Collaborator

    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.

  4. ahmedtd commented on Oct 28, 2026

    @ahmedtd
    Collaborator

    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?

  5. SHRUTI6991 commented on Oct 29, 2026

    @SHRUTI6991
    Collaborator

    The issue

    ate-api-server handles an atespace-scoped write in two separate steps:

    1. Authorize. UnaryServerInterceptor (cmd/ateapi/internal/authz/interceptor.go) calls authorizer.Check("can_create_access_policy", "atespace:config-race2"). OpenFGA reads the tuples straight from the connection pool, so this is its own autocommit transaction.
    2. Write. The handler opens a new transaction and inserts the policy and owner tuples by name: atespace_name = 'config-race2'. The FK on atespace_access_policies references atespaces(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

    t0  mallory: Check(owner on atespace:config-race2)    -> ALLOWED  (old tuples, uid_1)
    t1  admin:   DeleteAtespace(config-race2)              -> row uid_1 + tuples deleted, commit
    t2  admin:   CreateAtespace(config-race2)              -> row uid_2 inserted, commit
    t3  mallory: handler tx INSERT policy(config-race2)    -> FK on name matches uid_2, commit
    

    Result: the new atespace (uid_2) starts with mallory as owner, though she was never granted anything on it.

    DeleteAtespace has the same race. Mallory's delete is authorized against uid_1 but runs DELETE ... WHERE name = 'config-race2', so it deletes uid_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:

    Txn Who What
    T1 mallory's Check (autocommit, on the pool) reads owner tuple for atespace:config-race2, finds it
    T2 admin DeleteAtespace deletes the atespace row and its tuples
    T3 admin CreateAtespace inserts a new config-race2 row
    T4 mallory's handler tx inserts the policy and owner tuple

    Running 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.

    1. Interceptor: before Check, run SELECT uid FROM atespaces WHERE name = $1, then put expectedUID in the context.
    2. Handler transaction: fail unless the atespace still has that UID.
      • DeleteAtespace: set precondition.UID = expectedUID when the caller left it empty. The AND uid = $2 clause already exists.
      • Create, update and delete of the atespace access policy: run SELECT 1 FROM atespaces WHERE name = $1 AND uid = $2 FOR SHARE before writing. Return Aborted if no row matches.

    Why it's correct:

    • UID read before the delete: expectedUID = uid_1. At t3 the row is uid_2, the assert fails, and the write aborts.
    • UID read after the recreate: expectedUID = uid_2. The delete at t1 already removed mallory's tuples in the same transaction, so Check denies.

    It costs one primary-key lookup per request, with nothing to cache.

    Also in this change:

    • Add a regression test that runs delete and recreate alongside a concurrent policy create, and asserts the new incarnation has no policy.
    • Note in docs/authorization.md that 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:

    • OpenFGA objects: use atespace:<uid> instead of atespace:<name>.
    • Child tables: atespace_access_policies, actors, and the rest reference atespaces(uid) with ON DELETE CASCADE.
    • Interceptor: resolve name to UID once, run Check against atespace:<uid>, and pass the UID to the handler. Store writes take the UID.
    • A late write for uid_1 then fails on the FK, because that row is gone. uid_2 has 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.

  6. SHRUTI6991 commented on Oct 29, 2026

    @SHRUTI6991
    Collaborator

    Synched offline. We are documenting this as a known limitation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions