Skip to content

chore(auth): enumerate permission actions and resources, and check the TS unions against them - #1288

Merged
IAmJSD merged 2 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-166
Oct 26, 2026
Merged

IAmJSD merged 2 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-166

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Root cause

internal/auth declares its permission vocabulary as individual untyped consts (ActionView, ResourcePlans, ...). Go can't enumerate a const block, so nothing in Go could list the full set. The frontend's Action / Resource unions in frontend/src/permissions.ts were therefore a hand-kept mirror, and nothing detected them drifting from Go. That drift is what caused cli#1629, where the group-edit form silently dropped permissions and widened view:history to view:*.

Fix

  • internal/auth/permission_catalog.go (new file): auth.AllActions() and auth.AllResources() return the full sets in declaration order. They include admin and the * wildcard, and each call returns a fresh copy. I put them in a new file because types.go is already over 500 lines, and because this keeps the const blocks untouched.

  • Completeness doesn't depend on anyone remembering to update the list, which is the failure mode the issue warns about. TestPermissionCatalogMatchesConstDecls uses go/ast to parse every non-test .go file in internal/auth. It collects each package-level const named Action<Upper>… / Resource<Upper>…, including Go's implicit-repeat specs, and fails when:

    • a constant is declared but not enumerated
    • a value is enumerated but no constant declares it
    • a value is listed twice
    • two constants share a value
    • such a constant's value isn't a string literal (the test can't read it otherwise)

    Each failure names the constant and its position.

  • TestPermissionCatalogCoversSeededPermissions checks that every Default*Permissions() entry and every adminCarvedOuts pair uses only enumerated actions and resources.

  • Go-to-TS seam: cmd/gen-permissions/union_drift_test.go reads frontend/src/permissions.ts, parses the export type Action = … / export type Resource = … string-literal unions (comment-aware), and fails CI if either side has a value the other lacks. The frontend's existing Record<Action, true> check keeps ALL_ACTIONS equal to the union, and this test keeps the union equal to Go. Drift now fails go test ./... in CI. The test only reads the file. This PR changes nothing under frontend/.

I kept the enumeration as a listed array plus an AST exhaustiveness test. A go:generate step or restructuring the consts around a slice would have rewritten the heavily commented const blocks in types.go, and an open branch is already editing those blocks. This approach leaves types.go untouched.

Refs #166

(This is Refs, not a closing keyword, because the audit finding attached to the issue, A11-011 on permissionMatrix.ts, and the TS generation step both need frontend changes. See below.)

Verification

All evidence below is from unit tests on this branch. No DB, cloud or integration code is involved.

export PATH=$HOME/.local/go/bin:$HOME/go/bin:$HOME/.local/bin:$PATH
export AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true
go build ./... && go vet ./... && go vet -tags integration ./...      # OK
go test -count=1 ./cmd/gen-permissions/                                 # ok
go test -count=1 ./internal/auth/                                       # ok
go test -count=1 ./internal/mocks/                                      # ok
golangci-lint (v2.10.1) run --allow-parallel-runners ./internal/auth/ ./cmd/gen-permissions/   # 0 issues
gocyclo -over 10 -ignore "_test\.go" internal/auth/permission_catalog.go                       # clean

Fail-before / mutation probes. Each was applied temporarily and then reverted:

  • origin/main state (permission_catalog.go removed): both test packages fail to build (undefined: AllResources / undefined: auth.AllResources). Go has no enumeration to test against.
  • New const added, not enumerated (ActionFreeze = "freeze" added to the action block): TestPermissionCatalogMatchesConstDecls FAILS with ActionFreeze = "freeze" is declared but missing from the catalog in permission_catalog.go.
  • Enumerated in Go, frontend union not updated (also added to allActions): the auth tests pass, and TestFrontendPermissionUnionsMatchGo FAILS with Action union in ../../frontend/src/permissions.ts lacks Go value(s) ["freeze"].
  • Catalog entry dropped (ResourceRIExchange removed from allResources): the const-decl test FAILS (ResourceRIExchange = "ri-exchange" is declared but missing…), the seeded-permissions test FAILS (adminCarvedOuts uses ri-exchange), and the frontend drift test FAILS (Resource union … has ["ri-exchange"]).
  • Implicit-repeat const (ResourceAllAlias with no value after ResourceAll): FAILS with ResourceAll and ResourceAllAlias both have value "*".
  • Non-literal value (ResourceComputed = ResourcePlans + "-x"): FAILS with ResourceComputed must be a string literal….

TestTSUnionMembers covers the TS parser: line comments, block comments, a /* inside a line comment, // inside a string literal, double quotes, and a missing declaration.

I also checked with git merge-tree that the branch merges cleanly with our unopened fix/issue-313 branch, which removes the Role* constants. This change doesn't reference them.

Combined-tree verification. This branch was tested together with the other queued branches on a local, never-pushed tree: queue/platform @ 936996ad = origin/main a14dc8f2 plus these heads, merged in this order:

Branch Tested head
fix/issue-337 ff3420c
fix/issue-169 7d75eb5
fix/issue-342 66b221f
fix/issue-310 5e11c21
fix/issue-326 5f0ea59
fix/issue-243 365e959
fix/issue-343 6c94036
fix/issue-312 d2848a8
fix/issue-316 acd60dd
fix/issue-339 75ea9b9
fix/issue-339-commitment-type 73bdb19
fix/issue-353 5d25fcd
fix/issue-313 3156bc4
fix/issue-357 53d9e58
fix/issue-368 0ba727d
fix/issue-419 61648c9
fix/issue-111 b04c372
fix/issue-686 0ad90b7
fix/issue-674 793095d
fix/issue-1055 4ec96f1
fix/issue-559 dfa9525
fix/issue-558 034eed4
fix/issue-874 1720539
fix/issue-666 1e120da
fix/issue-668 fb26345
fix/issue-568 ce04173
fix/issue-311 e54f250
fix/issue-177 0cbb88a
fix/issue-151 c21377c
fix/issue-166 41fb77b
fix/issue-27 2d67622
fix/issue-323 69288ae
fix/issue-59 36463e8
fix/issue-43 35e4069
fix/issue-158 6f76f8a
fix/issue-104 bb00491
fix/issue-55 971ef1b
fix/issue-361 89ac220
fix/issue-120 b458719
fix/issue-36 42bac6e
fix/issue-525 57c97dd
fix/issue-712 e5bcda0
fix/issue-72 6cc63ed

fix/issue-72 and fix/issue-312 conflict on one line in internal/api/handler_dashboard.go (both change the calculateCommitmentMetrics call). The tree used #72's side with #312's validated provider variable; whichever PR merges second takes that same resolution.

Linux, Go 1.26.9, AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers:

  • Passed at 936996ad: go build ./..., go vet ./..., go vet -tags integration ./... and the full go test -race -short -count=1 ./....
  • Static checks at 936996ad: golangci-lint v2.10.1 found 0 issues on every changed package, with and without --build-tags integration. tests/e2e is a separate module, so it was vetted under its own e2e tag. gocyclo -over 10 is silent on the changed non-test files.
  • Integration tests, every changed package, one package at a time (-tags integration -count=1, 60-minute local timeout because the VM was shared):
    • At 936996ad, these pass: internal/server, internal/credentials, internal/scheduler, internal/database/postgres/migrations, internal/purchase and internal/api.
    • internal/config passes except TestPostgresStoreDB_ResolveStaleRIExchange_ValidatesCloudCheck, which fails the same way on unmodified main (fixed by fix(config): compare resolve checked_at at the database's microsecond precision #1252).
    • These were run on the first build of this tree (26795376, same branches at earlier heads): internal/analytics, internal/auth, internal/commitmentopts, internal/database and cmd/lambda/clear-rate-limit all pass. The later fixes don't touch those packages.
  • What the combined run caught, each fixed on its own branch before this PR opened:
    • a compile break between fix/issue-104's test and fix/issue-326's httpToLambdaRequest signature change;
    • fix/issue-316 rejecting legacy-keyed plans;
    • fix/issue-120 versus main's global-filter test.
  • Secrets: git-secrets, using the repo's scripts/setup-git-secrets.sh patterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged. scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.
  • Nothing was applied to any cloud.

Deliberately out of scope

  • Generating the TS unions with cmd/gen-permissions: that would change the committed frontend/src/permissions.generated.ts, and the UI owner owns frontend/. The drift test gets the same "CI fails on drift" result without editing the frontend. See the follow-up below.
  • No API endpoint exposing the vocabulary. The issue doesn't ask for one, so I didn't add API surface. AllActions()/AllResources() would make one trivial if it's ever wanted.
  • Constants declared outside internal/auth: the AST scan covers this package only. Handlers that pass bare string literals to permission checks elsewhere aren't covered.
  • Naming convention: the exhaustiveness check relies on the Action/Resource + upper-case prefix. A permission const named some other way wouldn't be seen, and the test docs say this.

Frontend follow-up for the UI owner

  1. Optional: generate the unions instead of hand-writing them. Extend cmd/gen-permissions to emit, for example, export const ACTIONS = [...] as const; export type Action = (typeof ACTIONS)[number]; (and the same for resources) from auth.AllActions() / auth.AllResources() into permissions.generated.ts. Then import those in permissions.ts, regenerate with go run ./cmd/gen-permissions, and commit the result. The existing permissions-codegen pre-commit hook then catches staleness, and cmd/gen-permissions/union_drift_test.go can be deleted or pointed at the generated file. Until that happens, a new verb needs updating in three places: the const in internal/auth/types.go, the list in internal/auth/permission_catalog.go, and the union plus *_EXHAUSTIVENESS_CHECK record in frontend/src/permissions.ts. The tests name whichever one was missed.
  2. A11-011: frontend/src/users/permissionMatrix.ts:11 still has its own seven-verb ACTIONS list, so the matrix shows no row for approve-any, retry-any, cancel-any, update-any, execute-any, sell-any, revoke-any or any -own verb. Fix: derive the matrix rows from ALL_ACTIONS, as groupModals.ts already does.
  3. Comment cleanup:
    • The permissions.ts header says the unions are hand-written mirrors. It could also mention that cmd/gen-permissions/union_drift_test.go now enforces them.
    • The "exactly one place to update" comment above ACTION_EXHAUSTIVENESS_CHECK holds within the frontend, but reads as repo-wide. The issue asks for it to be scoped.

Maintainer: please mirror the issue's triage labels onto this PR: impact/internal, type/chore, triaged, priority/p3, severity/low, urgency/eventually, effort/m.

🤖 Generated with Claude Code

…e TS unions against them

Go cannot enumerate a const block, so the frontend's Action and Resource
unions were a hand-maintained mirror with nothing detecting drift.

Add auth.AllActions() / auth.AllResources(). Their completeness is not left
to memory: a go/ast test parses every non-test file in internal/auth and
fails when an Action*/Resource* constant is declared without being
enumerated (or enumerated without being declared), when such a constant is
not a string literal, or when two share a value. A second test checks that
the default group permissions and admin carve-outs use only enumerated
values.

cmd/gen-permissions gains a read-only test that parses the Action and
Resource unions in frontend/src/permissions.ts and fails CI on drift in
either direction. No frontend file changes.

Refs LeanerCloud#166

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added impact/internal Team-internal only type/chore Maintenance / non-user-visible triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm urgency/eventually No deadline effort/m Days labels Oct 23, 2026
@coderabbitai

coderabbitai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 3 billable files and costs up to $0.75.

Or wait 59 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 503eb2e and 9cf7097.

Files selected for processing: 3
  • cmd/gen-permissions/union_drift_test.go
  • internal/auth/permission_catalog.go
  • internal/auth/permission_catalog_test.go
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

Brings in main's CI fixes (LeanerCloud#1236, LeanerCloud#1252) and CI changes since, so this PR gets a fresh CI run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE 9cf70973bd2c7869ad246a7ceaa0d72dff6a12b8

Blocking:

  • none

Non-blocking:

  • internal/auth/permission_catalog_test.go declaredPermissionConsts: the scan only sees package-level consts in internal/auth named Action<Upper>… or Resource<Upper>…. Some cases slip past it:

    • a verb declared under another naming scheme or in another package;
    • a verb used as a bare string literal in a handler. TestPermissionCatalogCoversSeededPermissions only covers the Default*Permissions sets and adminCarvedOuts.

    The reverse also fails: a future unrelated const such as ActionTimeout would trip the test as a false positive. That is acceptable, since it fails loudly, but a one-line note in the catalog comment would save someone a puzzle.

  • The issue's preferred end state, cmd/gen-permissions emitting the TS unions, is replaced by a read-only drift test. That is a reasonable call and the body says so. Refs #166 (not Closes) is right, because A11-011 (permissionMatrix.ts) and the issue's nit about the "exactly one place to update" comment are still open.

  • AllActions() and AllResources() have no production caller yet. For now they exist only for the tests.

Attacked:

  • Head checked: 9cf70973. This PR adds no runtime auth behavior; it adds a catalog and two test guards. I checked the guards against the issue's warning: "Do not add a hand-maintained Go slice and call it done". The AST exhaustiveness test is the issue's "linter check" option and does not depend on anyone remembering to update the list.
  • CI wiring: ci.yml runs go test -race -short ./... on every PR with no path filter. A frontend-only edit to permissions.ts therefore still runs the drift test.
  • Mutation probes, each reverted:
    • A new ActionFreeze const in a separate file fails TestPermissionCatalogMatchesConstDecls ("declared but missing from the catalog").
    • Adding it to the catalog fails TestFrontendPermissionUnionsMatchGo ("lacks Go value(s) ["freeze"]").
    • Adding 'bogus' to the TS Action union fails it the other way ("has ["bogus"]").
  • Confirmed the current Go consts (20 actions, 11 resources) match the TS unions.
  • Commands run: go build ./...; go vet on both packages, with and without -tags integration; go test -race -short -count=1 ./internal/auth/ ./cmd/gen-permissions/ (ok); golangci-lint v2.10.1 on both packages (0 issues).

Body-stale:

  • none

Reviewer: rB19. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE 9cf70973bd2c7869ad246a7ceaa0d72dff6a12b8

Blocking:

  • none

Non-blocking:

  • internal/auth/permission_catalog_test.go declaredPermissionConsts: the exhaustiveness scan only reads non-test files in internal/auth. If a permission verb is ever declared in another package, or written as a bare string literal at a call site (for example in internal/api), the scan won't see it. TestPermissionCatalogCoversSeededPermissions covers the seeded sets, which is where it matters most today. This is fine for the stated scope.
  • cmd/gen-permissions/union_drift_test.go stripTSComments: this is a small hand-rolled lexer. It doesn't handle template literals or regex literals, and an unpaired apostrophe in code outside a comment would mis-lex up to the end of that line. Neither occurs in permissions.ts today. If the file grows, a failure will be loud (a missing union or a spurious diff), not a silent pass, so this is acceptable.
  • The issue's preferred end state is generating the TS unions from Go. This PR checks the hand-written unions against Go instead. Both make drift fail CI. The PR body says so, and Refs #166 is correct because A11-011 (permissionMatrix.ts) and the scoping nit on the "exactly one place to update" comment are still open.

Attacked:

  • Checked that CI picks up the new test: ci.yml runs go test -v -race -short ./... from the repo root with no path filters, so the test runs on frontend-only PRs too. The drift test reads the file through a relative path from the package dir, which is the cwd under go test.
  • Fail-before (mutation), reverted afterwards:
    • Adding | 'freeze' to the TS Action union makes TestFrontendPermissionUnionsMatchGo fail with has ["freeze"], which auth.AllActions() does not enumerate.
    • Adding ActionFreeze = "freeze" to types.go without listing it makes TestPermissionCatalogMatchesConstDecls fail with ActionFreeze = "freeze" is declared but missing from the catalog.
  • I confirmed that every package-level Action*/Resource* const in internal/auth is a permission constant. The scan's naming convention has no false matches today.
  • Checks run: go build ./.... go vet on both packages, with and without -tags integration. go test -race -short -count=1 ./internal/auth/ ./cmd/gen-permissions/. golangci-lint v2.10.1 on both packages, with and without the integration tag: 0 issues. gocyclo -over 10 on permission_catalog.go: clean. The head merges cleanly with main 5a2e31e, and the tests pass on that merge too.

Body-stale:

  • none

Reviewer: rB20. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification at 9cf70973bd2c7869ad246a7ceaa0d72dff6a12b8 merged onto main (eb9e4802), together with #1277, #1284, #1288, #1290, #1294, #1306 in that order (combined tree 6bb85247, Linux, Go 1.26.10): go build ./..., go vet with and without -tags integration, the full go test -race -short -count=1 ./..., and go test -race -tags integration -count=1 on every changed package, against Docker Postgres. All pass. CI Success and pre-commit are green on this head, with 2 approving independent review(s) on it. Nothing was run against any cloud.

@IAmJSD
IAmJSD marked this pull request as ready for review October 26, 2026 00:00
@IAmJSD
IAmJSD merged commit c0eb64c into LeanerCloud:main Oct 26, 2026
34 checks passed
@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on 9cf70973 (two required: money, auth or security path), green CI, and the combined-tree verification above. Squash-merged at exactly 9cf70973bd2c7869ad246a7ceaa0d72dff6a12b8.

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

Labels

effort/m Days impact/internal Team-internal only priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/chore Maintenance / non-user-visible urgency/eventually No deadline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant