Skip to content

feat: report side-effect principal deletion from revoke - #141

Merged
alan-lee-12 merged 8 commits into
mainfrom
fde-296-revoke-deletes-user
Jul 30, 2026
Merged

feat: report side-effect principal deletion from revoke#141
alan-lee-12 merged 8 commits into
mainfrom
fde-296-revoke-deletes-user

Conversation

@alan-lee-12

@alan-lee-12 alan-lee-12 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

What

Some downstream apps delete the user record when their last role is revoked. Today C1 doesn't learn the account is gone until the next full sync (~1h), so an immediate role change (revoke old → request new) fails downstream because the account no longer exists.

This adds an optional provisioning.revoke.revoke_options.principal_exists_check probe. After the revoke queries run (same transaction), the probe checks whether the principal still exists — a positive existence query, the same shape as account_provisioning.validate. If it returns no rows, the principal was deleted as a side effect, and the connector attaches a ResourceDeleted annotation to the revoke response so C1 can mark the account deleted immediately.

Changes

  • config (pkg/bsql/config.go, pkg/bsql/validate.go): a revoke-only RevokeEntitlementProvisioningQueries type with a revoke_options block carrying principal_exists_check { query }, plus validation (non-empty query + var check)
  • pkg/bsql/query.go: RunRevokeProvisioning + runPrincipalExistsCheck — runs the probe on the same executor/tx after the revoke queries; no rows ⇒ principal deleted. Tolerates the all-zero-rows (already-revoked) case so retried revokes still report the deletion.
  • pkg/bsql/provisioning.go: Revoke() attaches ResourceDeleted{principal.Id}, combined with GrantAlreadyRevoked on the already-revoked retry path.
  • examples/mssql-revoke-deletes-user.yml: worked example (delete-user-on-last-role revoke + probe + re-provisioning)
  • tests: pkg/bsql/provisioning_revoke_deleted_test.go + config round-trip in config_test.go

Design note

The probe is a positive existence query (rows present = principal still exists), mirroring account_provisioning.validate; "no rows ⇒ deleted" is the single interpretation. This keeps the SQL natural (no NOT EXISTS gymnastics) and consistent with the existing validate pattern.

Dependency / merge order

Depends on the ResourceDeleted annotation in ConductorOne/baton-sdk#1010. This branch is intentionally source-onlygo.mod / go.sum / vendor/ are not included, so it won't build standalone until the SDK PR is released and the dependency is bumped here (dropping the local replace directive used during development).

Test plan (built against the local SDK via replace)

  • go build ./... && go test ./... -count=1 — pass
  • gofmt clean; golangci-lint run ./pkg/bsql/... — clean aside from the dev-only replace directive

Refs FDE-296

🤖 Generated with Claude Code

@linear-code

linear-code Bot commented Jul 15, 2026

Copy link
Copy Markdown

FDE-296

@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: feat: report side-effect principal deletion from revoke

Blocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0
Criteria: loaded .claude/skills/ci-review.md from trusted base 71ce7c786a18
Review mode: full
View review run

Review Summary

Scanned the full PR diff for security and correctness. The change adds an optional revoke_options.principal_exists_check probe that runs after the revoke queries on the same executor and attaches a ResourceDeleted annotation when the principal is gone. The new RunRevokeProvisioning/runPrincipalExistsCheck code mirrors the existing provisioning transaction/rollback pattern, closes result rows correctly, and the already-revoked + deleted retry path is handled. The Revoke field type change to RevokeEntitlementProvisioningQueries stays YAML-compatible via inline embedding, and the go.mod/go.sum/vendor changes keep baton-sdk pinned at v0.20.4 (the nistec/vendor additions are go-mod-tidy/go-mod-vendor hygiene, not a version bump). Tests cover the delete, keep-alive, all-zero-rows, probe-error rollback, and retry cases against a real sqlite DB. No new issues found; the previously-raised no_transaction documentation caveat (the probe runs on the raw DB handle rather than the revoke transaction, so the deletion report is best-effort in that mode) appears still unaddressed but is not re-flagged here.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

alan-lee-12 and others added 5 commits July 27, 2026 15:01
Some apps delete the user when their last role is revoked. Add an optional
provisioning.revoke.principal_deleted_check probe that runs after the revoke
queries on the same transaction; when it finds the principal gone, the revoke
response carries a ResourceDeleted annotation so ConductorOne marks the
account deleted immediately instead of waiting for a full sync.

- config: PrincipalDeletedCheck struct/field + validation
- query.go: RunRevokeProvisioning + runPrincipalDeletedCheck probe
- provisioning.go: attach ResourceDeleted (combined with GrantAlreadyRevoked
  on the already-revoked retry path)
- examples/mssql-revoke-deletes-user.yml + tests

Depends on the baton-sdk ResourceDeleted annotation; the go.mod/vendor bump to
the released SDK is handled separately.

Refs FDE-296

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Group revoke-only probe config under revoke.revoke_options so future revoke
knobs have a clear home without polluting the shared provisioning query fields.

Co-authored-by: Cursor <cursoragent@cursor.com>
The probe is a positive existence query — it returns the principal when it
still exists, mirroring account_provisioning.validate. The previous name
described the downstream consequence rather than the query, which forced a
double-negative read ("write an exists query, but zero rows means deleted").

Behavior is unchanged: the exists-check returning no rows still means the
principal was deleted by the revoke, yielding a ResourceDeleted annotation.

Refs FDE-296

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pin to the released SDK that provides the ResourceDeleted annotation used by revoke principal-exists-check reporting.

Co-authored-by: Cursor <cursoragent@cursor.com>
@alan-lee-12
alan-lee-12 force-pushed the fde-296-revoke-deletes-user branch from f45dc4a to 2e8342c Compare July 27, 2026 22:03

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@alan-lee-12
alan-lee-12 marked this pull request as ready for review July 27, 2026 23:03
@alan-lee-12
alan-lee-12 requested a review from a team July 27, 2026 23:03
Comment thread pkg/bsql/provisioning.go
useTx,
)

anno := annotations.Annotations{}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you need to check the error on line 142 before we use principalDeleted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 fixed in 88e41bc

Comment thread pkg/bsql/query.go Outdated
var exists bool
exists, err = s.runPrincipalExistsCheck(ctx, executor, existsCheck, vars)
if err != nil {
return false, err

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if we should return a typed error here so the caller knows if the actual revoke query failed versus the optional check. Also we probably need to wait to run this until after we commit the transaction so that our existence check runs after we do the revoke.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • run check after transaction: fixed in 24647f6
  • looking into the typed error suggestion..

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

Comment thread pkg/bsql/query.go Outdated
if existsCheck != nil {
exists, err := s.runPrincipalExistsCheck(ctx, target, existsCheck, vars)
if err != nil {
l.Error(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: This is graceful degradation — the revoke already committed and the connector continues, treating the failure as "not deleted." Per the repo log-level criteria, a gracefully-handled probe failure (often a customer-supplied query or transient DB issue) should be Warn rather than Error to avoid alert noise and retained OTEL error spans. The connector doesn't stop and the condition isn't a connector code bug, so l.Warn(...) fits better here.

@jirwin jirwin Jul 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think set this to warn, and explicitly set exists = false if there is an error, and this is good to go.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✔️ 508872b

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: feat: report side-effect principal deletion from revoke

Blocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 71ce7c786a18.
Review mode: full
View review run

Review Summary

Scanned the full PR diff for security and correctness. This adds an optional revoke_options.principal_exists_check probe that runs after revoke queries commit and attaches a ResourceDeleted annotation when the principal was deleted as a side effect. The probe correctly runs outside the revoke transaction so a probe failure cannot roll back a committed revoke (logged at Warn, deletion left unreported), zero-row/already-revoked paths still probe so retried revokes surface the deletion, and the change is behavior-additive (no config gets ResourceDeleted unless it opts in). SQL uses the existing bound-parameter/identifier-sanitization path, so no new injection surface. go.mod/go.sum/vendor are now present and bumped to baton-sdk v0.20.4 (which provides NewResourceDeleted) with no replace directive, so it builds despite the stale "source-only" note in the PR description. The prior review suggestion (probe failure logged at l.Error) is addressed: it is now l.Warn at pkg/bsql/query.go:489. Tests cover last-role deletion, surviving principal, zero-row retry, probe-error tolerance, and annotation presence/absence. No new issues found.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@alan-lee-12

alan-lee-12 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Manual e2e verification (local postgres) — Stage A

Verified revoke_options.principal_exists_check end-to-end against a real PostgreSQL 16 (2026-07-28, this branch). Test assets are local-only and intentionally not part of this PR.

Setup

  • Dockerized postgres seeded with users / roles / user_roles: alice (analyst+admin), bob (analyst only), carol (control).
  • Connector config mirroring examples/mssql-revoke-deletes-user.yml, postgres flavor: revoke deletes the membership row, then the user row if it was their last role, with revoke_options.principal_exists_check probing SELECT 1 FROM users WHERE username = ?<username>.
  • Full sync to a local .c1z, then revokes/grants driven via the SDK's hidden --revoke-grant / --grant-entitlement flags with -p --log-level debug.
Test config yaml
app_name: PostgreSQL Revoke-Deletes-User Test
app_description: Demonstrates revoke_options.principal_exists_check for apps that delete a user when their last role is revoked

connect:
  dsn: "postgres://${DB_HOST}:${DB_PORT}/${DB_DATABASE}?sslmode=disable"
  user: "${DB_USER}"
  password: "${DB_PASSWORD}"

resource_types:
  user:
    name: "User"
    description: "A user within the PostgreSQL system"
    list:
      query: |
        SELECT
          u.id,
          u.username,
          u.email,
          u.status
        FROM users u
        ORDER BY u.id
      pagination:
        strategy: "offset"
        primary_key: "id"
      map:
        id: ".username"
        display_name: ".username"
        description: ".username"
        traits:
          user:
            emails:
              - ".email"
            status: ".status"
            login: ".username"

    # Account provisioning so that a re-grant immediately after deletion
    # recreates the user before the new role is assigned.
    account_provisioning:
      schema:
        - name: "username"
          description: "The username for the new user"
          type: "string"
          placeholder: "new_user"
          required: true
        - name: "email"
          description: "Email address for the new user"
          type: "string"
          placeholder: "user@example.com"
          required: true
      credentials:
        no_password:
          preferred: true
      validate:
        vars:
          username: "username"
        query: |
          SELECT u.id, u.username, u.email
          FROM users u
          WHERE u.username = ?<username>
      create:
        vars:
          username: "input.username"
          email: "input.email"
        queries:
          - |
            INSERT INTO users (username, email, status, created_at)
            VALUES (?<username>, ?<email>, 'active', NOW())
            ON CONFLICT (username) DO NOTHING

  role:
    name: "Role"
    description: "A role within the PostgreSQL system"
    list:
      query: |
        SELECT
          id,
          role_name,
          description
        FROM roles
        ORDER BY id
      pagination:
        strategy: "offset"
        primary_key: "id"
      map:
        id: ".role_name"
        display_name: ".role_name"
        description: ".description"
        traits:
          role:
            profile:
              role_name: ".role_name"

    static_entitlements:
      - id: "member"
        display_name: "resource.DisplayName + ' Membership'"
        description: "'Member of the ' + resource.DisplayName + ' role'"
        purpose: "assignment"
        grantable_to:
          - "user"
        provisioning:
          vars:
            username: "principal.ID"
            role_name: "resource.ID"
          grant:
            queries:
              - |
                INSERT INTO user_roles (user_id, role_id)
                SELECT u.id, r.id
                FROM users u, roles r
                WHERE u.username = ?<username>
                AND r.role_name = ?<role_name>
                ON CONFLICT (user_id, role_id) DO NOTHING
          revoke:
            # Revoke removes the membership row, then deletes the user if this
            # was their last role. The queries run in a single transaction.
            queries:
              - |
                DELETE FROM user_roles ur
                USING users u, roles r
                WHERE ur.user_id = u.id
                AND ur.role_id = r.id
                AND u.username = ?<username>
                AND r.role_name = ?<role_name>
              - |
                DELETE FROM users
                WHERE username = ?<username>
                AND NOT EXISTS (
                  SELECT 1 FROM user_roles ur
                  JOIN users u2 ON ur.user_id = u2.id
                  WHERE u2.username = ?<username>
                )
            # After the revoke queries run, probe whether the principal still
            # exists. No rows means the app deleted the user, so the connector
            # reports a ResourceDeleted annotation on the revoke response.
            revoke_options:
              principal_exists_check:
                query: |
                  SELECT 1 FROM users WHERE username = ?<username>

    grants:
      - query: |
          SELECT
            u.username,
            r.role_name
          FROM user_roles ur
          JOIN users u ON ur.user_id = u.id
          JOIN roles r ON ur.role_id = r.id
          ORDER BY r.role_name, u.username
        map:
          - skip_if: ".role_name != resource.ID"
            principal_id: ".username"
            principal_type: "user"
            entitlement_id: "member"
        pagination:
          strategy: "offset"
          primary_key: "username"
Test fixture
CREATE TABLE users (
    id         SERIAL PRIMARY KEY,
    username   TEXT NOT NULL UNIQUE,
    email      TEXT NOT NULL,
    status     TEXT NOT NULL DEFAULT 'active',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE roles (
    id          SERIAL PRIMARY KEY,
    role_name   TEXT NOT NULL UNIQUE,
    description TEXT
);

CREATE TABLE user_roles (
    user_id INT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
    role_id INT NOT NULL REFERENCES roles (id),
    PRIMARY KEY (user_id, role_id)
);

INSERT INTO roles (role_name, description) VALUES
    ('analyst', 'Read-only analyst role'),
    ('admin',   'Administrator role'),
    ('auditor', 'Compliance auditor role');

INSERT INTO users (username, email) VALUES
    ('alice', 'alice@example.com'),
    ('bob',   'bob@example.com'),
    ('carol', 'carol@example.com');

INSERT INTO user_roles (user_id, role_id)
SELECT u.id, r.id FROM users u, roles r
WHERE (u.username, r.role_name) IN (
    ('alice', 'analyst'),
    ('alice', 'admin'),
    ('bob',   'analyst'),
    ('carol', 'admin')
);

Results

Scenario Expected Result
Revoke bob's last role both revoke queries affect 1 row; info log revoke deleted principal; reporting ResourceDeleted (principal_id: bob); user row gone; other users untouched
Revoke alice's non-last role user-delete query affects 0 rows; probe finds her; no ResourceDeleted; row intact
Retry bob's revoke (grant + user already gone) all queries 0 rows ⇒ already-revoked path, still reports ResourceDeleted (idempotent retry)
Grant via --grant-entitlement membership row re-inserted (state restore between runs)
sample logs when last role was deleted
{"level":"debug","ts":1785274092.702752,"caller":"bsql/provisioning.go:109","msg":"revoking entitlement","grpc.start_time":"2026-07-28T14:28:12-07:00","grpc.service":"c1.connector.v2.GrantManagerService","grpc.method":"Revoke","peer.address":"127.0.0.1:53502","grant_id":"role:analyst:member:user:bob"}
{"level":"info","ts":1785274092.702773,"caller":"bsql/provisioning.go:31","msg":"provisioning is enabled for entitlement","grpc.start_time":"2026-07-28T14:28:12-07:00","grpc.service":"c1.connector.v2.GrantManagerService","grpc.method":"Revoke","peer.address":"127.0.0.1:53502","entitlement_id":"member"}
{"level":"debug","ts":1785274092.7158709,"caller":"bsql/query.go:674","msg":"query executed","grpc.start_time":"2026-07-28T14:28:12-07:00","grpc.service":"c1.connector.v2.GrantManagerService","grpc.method":"Revoke","peer.address":"127.0.0.1:53502","use_tx":true,"peer.address":"127.0.0.1:53502","query":"DELETE FROM user_roles ur\nUSING users u, roles r\nWHERE ur.user_id = u.id\nAND ur.role_id = r.id\nAND u.username = $1\nAND r.role_name = $2\n","args":["bob","analyst"],"rows_affected":1}
{"level":"debug","ts":1785274092.71793,"caller":"bsql/query.go:674","msg":"query executed","grpc.start_time":"2026-07-28T14:28:12-07:00","grpc.service":"c1.connector.v2.GrantManagerService","grpc.method":"Revoke","peer.address":"127.0.0.1:53502","use_tx":true,"peer.address":"127.0.0.1:53502","query":"DELETE FROM users\nWHERE username = $1\nAND NOT EXISTS (\n  SELECT 1 FROM user_roles ur\n  JOIN users u2 ON ur.user_id = u2.id\n  WHERE u2.username = $2\n)\n","args":["bob","bob"],"rows_affected":1}
{"level":"debug","ts":1785274092.7189739,"caller":"bsql/query.go:579","msg":"running principal exists check query","grpc.start_time":"2026-07-28T14:28:12-07:00","grpc.service":"c1.connector.v2.GrantManagerService","grpc.method":"Revoke","peer.address":"127.0.0.1:53502","use_tx":true,"peer.address":"127.0.0.1:53502","query":"SELECT 1 FROM users WHERE username = $1\n","args":["bob"]}
{"level":"info","ts":1785274092.720174,"caller":"bsql/provisioning.go:160","msg":"revoke deleted principal; reporting ResourceDeleted","grpc.start_time":"2026-07-28T14:28:12-07:00","grpc.service":"c1.connector.v2.GrantManagerService","grpc.method":"Revoke","peer.address":"127.0.0.1:53502","grant_id":"role:analyst:member:user:bob","principal_id":"bob"}
{"level":"debug","ts":1785274092.720187,"caller":"bsql/provisioning.go:172","msg":"revoked grant","grpc.start_time":"2026-07-28T14:28:12-07:00","grpc.service":"c1.connector.v2.GrantManagerService","grpc.method":"Revoke","peer.address":"127.0.0.1:53502","grant_id":"role:analyst:member:user:bob"}

Unit coverage for the remaining edges (no principal_exists_check configured ⇒ unchanged behavior; probe error ⇒ warn + treated as not-deleted) lives in pkg/bsql/provisioning_revoke_deleted_test.go.

Next: Stage B — service mode against a dev tenant with the c1-side consumer + feature flag, exercising revoke-from-C1, immediate re-provision, and FF-off control.

@johnallers johnallers self-assigned this Jul 30, 2026
@alan-lee-12
alan-lee-12 merged commit 7b25a64 into main Jul 30, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants