feat(iam): move user promote/demote onto the RBAC role API (#270) - #409
Conversation
cmd/workspace built its own callback set because it was the only caller that needed a workspace-scoped MFA link instead of a server-scoped one. A role binding needs the same thing for the same reason, so move the set to WorkspaceErrorCallbacks beside ErrorCallbacks and let cmd/workspace's local errorCallbacks delegate to it. mfa.ErrorCallbacks is not an option for either caller: its OnMFARequired goes through GetMFALinkByServerName, and a workspace-wide change names no server, so the lookup would be handed an empty name.
SendDeleteRequestWithBody exists because /api/rbac/user-roles/ takes its revocation justification in the DELETE body rather than a query parameter, so the reason stays out of access logs, proxy logs and shell history. createRequest sets the content type only for the methods that always carry a body, so this sets it itself. IsBearerAuth answers which credential the client actually sent. Some endpoints refuse a legacy API key with a 403 carrying no error code, and a caller rewriting that refusal cannot tell it from lacking the privilege without knowing. It reads under tokenMu because sendRequest renews the access token mid-flight.
The role catalog, a user's bindings, the grant and revoke writes, the audit-log
provenance read, and the effective-permission reads the iam user endpoint
carries. Nothing under cmd/ uses it yet.
Three details of the server contract are load-bearing and are pinned here
rather than left to each caller. BindingCreateRequest takes scalars: the server
switches to a bulk path as soon as any of user, role or object_id carries more
than one value, and that path answers 201 with an empty body whether or not it
wrote anything. Role on a binding read is a nested {id, name} object, because
the serializer overwrites its own primary-key field in to_representation.
ContentType and ObjectID are pointers and stay on the request though no command
sets them, so object-scoped writes can be added without touching the signature.
The nested user object a role binding carries holds a display name, not the username the other commands accept. Any list of principals an operator is meant to type back into another command has to join against this.
ls, catalog, describe and history, hung off the existing user command. The
surface follows the convention for human identity rather than the one for
machine principals: Auth0, which this product authenticates through, and
Teleport, the closest peer, both put the account first and the role second.
Counted across every CLI surveyed the role noun wins 6 to 3, but those six are
cloud IaaS tools whose principals are usually machines.
describe carries the holder list as well as the permissions, so one command
answers both "what does this role grant" and "who holds it" - the second
question is the one kubectl's RBAC surface cannot ask at all.
No command takes a --role flag. --role occurs exactly once in cmd/ today, on
group member add, where it means the group-membership tier, so after this the
name still means one thing binary-wide. member_add's flag help now says so.
rbac_errors.go rewrites the server's bare {"code": ...} envelope into something
actionable, and disambiguates the codeless 403 by the credential the client
carried.
Promotion and demotion have had no CLI path since alpacon-server made is_staff/is_superuser read-only. Both verbs take one user and one role, which makes a multi-target write unrepresentable in the grammar and so closes the bulk-path trap structurally rather than by a runtime check. Neither verb claims success from a 2xx. Both re-read the user's bindings and report the rows actually observed, which is how the admin companion the server creates for a superuser grant is discovered rather than assumed. A grant of a role already held converges to exit 0 without a POST, and a racing duplicate maps to the same outcome. revoke refuses to remove admin from someone still holding superuser before it sends anything: the server accepts that delete, then re-forces is_staff because is_superuser still stands, leaving both flags set while the account no longer registers as an admin. --cascade removes both, superuser first; the reverse order leaves a partial run that fails open. It plans the companion only when the named binding was found, because a user who holds admin and never held superuser has the same binding list as one whose cascade was interrupted. Refs #270
UpdateUser round-tripped the whole user detail body through $EDITOR and PATCHed it back with no field allow-list, so an operator who set is_staff there got a green "User updated" and no privilege change. The server answers 200 and drops the flags - never an error - so nothing in the exchange said so. Split it into PrepareUserUpdate, which runs the editor session and reports what changed, and PatchUser, which sends. The patch is a sparse top-level diff, so an edited flag can be told from the untouched copy the detail response always carries. Privilege edits are held back and reported with the exact 'alpacon user role' command that performs them, and an edit that touched one never exits 0, whether or not the rest of it applied. The sparse diff fixes a second bug for free: is_ldap_user is not read-only, so every update re-submitted it and made the server run a live LDAP bind and lookup, which could fail the whole request over a field nobody touched. Closes #270
Role membership and effective access are different questions, and every tool surveyed keeps them in separate commands. ls reports the roles in effect and the capabilities they add up to; can-i answers one permission, spelled the way kubectl spells it, with the subject optional so a single argument asks about yourself. can-i prints yes or no and exits 0 either way, so a denial is never mistaken for a failed request. Only -q makes the exit code the answer, and there a denial and a failed check share 1 - the help says so and points at --output json. --explain and -q are mutually exclusive: an explanation has no boolean for an exit code to carry.
diffEditedUser is table-driven over the six cases that matter: an untouched buffer, an ordinary field, a privilege flag alone, a mixed edit, a cleared flag, and a deleted key. It runs against the helper directly, so no test has to drive $EDITOR - CreateAndEditTempFile execs the editor with no argument splitting and is not scriptable. The grant body gets one JSONEq over the whole request rather than per-field asserts, because a per-field check would not notice user or role turning into an array - which is the difference between the reporting path and the silent bulk one. plannedRevocations is pinned including the case that must plan nothing: a user holding admin who never held superuser. The --role walk covers the whole UserCmd tree rather than a list of paths, so a leaf added later is held to the same rule.
A "Workspace roles" section under Identity, and a note on the exit-code table that 'user permission can-i -q' answers a denial with 1, which is indistinguishable from a failed check by exit code alone.
There was a problem hiding this comment.
🟡 Changes recommended
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR migrates user promotion/demotion semantics in the Alpacon CLI away from PATCH /api/iam/users/{id}/ privilege flags and onto the RBAC role-binding API (/api/rbac/user-roles/), adding a new alpacon user role / alpacon user permission surface and hardening alpacon user update to avoid “false green” privilege edits.
Changes:
- Added an
api/rbacclient package plus newalpacon user role ...andalpacon user permission ...command trees underalpacon user. - Reworked
alpacon user updateto generate a sparse patch, explicitly detect privilege-flag edits, and avoid resubmitting unchanged fields likeis_ldap_user. - Added shared workspace-scoped MFA callbacks, DELETE-with-body support, and updated docs/tests to lock in RBAC API behavior and CLI contracts.
File summaries
| File | Description |
|---|---|
| README.md | Documents the new workspace role/permission commands and clarifies can-i -q exit-code semantics. |
| cmd/workspace/error_callbacks.go | Refactors workspace update MFA callbacks to use mfa.WorkspaceErrorCallbacks. |
| cmd/iam/user.go | Registers user role and user permission under alpacon user. |
| cmd/iam/user_update.go | Changes user update flow to sparse patching and blocks privilege-flag edits with actionable guidance. |
| cmd/iam/user_role.go | Adds the alpacon user role command group and shared subject resolution logic. |
| cmd/iam/user_role_test.go | Adds tests for cascade revoke planning, argument parsing, command registration, and flag meaning. |
| cmd/iam/user_role_revoke.go | Implements role revocation (including --cascade) with MFA retry and safety invariants. |
| cmd/iam/user_role_list.go | Implements role-binding listing for a user (or the caller). |
| cmd/iam/user_role_history.go | Implements audit-log history listing with --tail. |
| cmd/iam/user_role_grant.go | Implements role grant with duplicate convergence handling and MFA retry. |
| cmd/iam/user_role_describe.go | Implements role description combining permissions + holders (table/JSON). |
| cmd/iam/user_role_catalog.go | Implements role catalog listing with optional object-role hiding filter. |
| cmd/iam/user_permission.go | Adds the alpacon user permission command group. |
| cmd/iam/user_permission_list.go | Implements effective permission listing with an optional patterns-only mode. |
| cmd/iam/user_permission_cani.go | Implements can-i checks with -q/--explain behavior and argument disambiguation. |
| cmd/iam/rbac_errors.go | Adds RBAC-specific error rewriting, including credential-kind-aware 403 messaging. |
| cmd/iam/member_add.go | Clarifies that --role is group-tier role, not workspace RBAC role. |
| cmd/iam/json_slice.go | Adds helper to ensure empty arrays render as [] (not null) in hand-built JSON output. |
| client/client.go | Adds DELETE-with-body support and IsBearerAuth() to support RBAC refusal rewriting. |
| CLAUDE.md | Updates repository guidance to mention mfa.WorkspaceErrorCallbacks usage. |
| api/rbac/types.go | Defines RBAC DTOs and table-projection structs plus scope labeling helpers. |
| api/rbac/rbac.go | Implements RBAC API calls (roles, bindings, audits, permissions) and projections. |
| api/rbac/rbac_test.go | Adds contract tests for RBAC API request/response shapes and projections. |
| api/mfa/mfa.go | Introduces WorkspaceErrorCallbacks for workspace-scoped MFA prompts/retries. |
| api/iam/user_edit_test.go | Adds tests for sparse user-edit diffing and privilege-flag suppression. |
| api/iam/types.go | Adds UserEdit and PrivilegeEdit types to represent editor-session outcomes. |
| api/iam/iam.go | Replaces UpdateUser with PrepareUserUpdate + PatchUser and adds diff logic for sparse patches. |
Review details
- Files reviewed: 27/27 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
warnMissingReason judged strings.TrimSpace(reason) while the untrimmed value was what went into the request, so --reason " " both warned that the audit entry would carry no justification and filled it with blanks - which also kept the server's own unjustified-grant warning quiet. Read and trim the flag in one place, reasonFlag, shared by grant and revoke. An empty reason is dropped from the request entirely by omitempty, so an omission now reaches the server as an omission.
There was a problem hiding this comment.
🔵 Needs a closer look
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
cmd/iam/rbac_errors.go:69
- The codeless 403 rewrite always leads with “A role-binding write requires the superuser role”, but
describeRBACErroris also used by read-only commands (catalog/ls/describe/permission reads). In those cases the message is misleading, because the failure may simply be “API token refused on Alpacon Cloud” or “read access is limited”, not a missing write privilege. Reword the fallback so it accurately covers both read and write contexts while still pointing token users toalpacon loginwhen relevant.
if !ac.IsBearerAuth() {
return errors.New("refused without a stated reason. A role-binding write requires the superuser role. This credential may also be the problem: the RBAC API refuses API tokens outright on Alpacon Cloud workspaces—run 'alpacon login' to authenticate through the browser—and the role history needs a token carrying the role_audit_log:read scope")
}
return errors.New("refused without a stated reason. A role-binding write requires the superuser role; a read is limited to the accounts and roles this workspace lets you see")
- Files reviewed: 27/27 changed files
- Comments generated: 0 new
- Review effort level: Lite
describeRBACError is wired into every new command, and its codeless-403 branch led with "a role-binding write requires the superuser role" for all of them. Ten of the seventeen call sites are reads, which write nothing, so the first thing the operator read was about a privilege the request never needed. The three surfaces refuse for three different reasons, and they are not interchangeable: a read on /api/rbac/ is refused for an API token on an Alpacon Cloud workspace and for a token missing role_audit_log:read on the audit log; a binding write additionally needs the superuser role; and the introspection reads hosted on /api/iam/users/ accept tokens and want user:read on the target account. Re-running 'alpacon login' does not grant a superuser role, and holding one does not make a cloud workspace accept an API token, so naming the wrong gate sends the operator after a fix that cannot work. Callers now pass the gate. The zero value is the read gate, so a caller that forgets says the milder thing. Also fixes the test fixture for a coded refusal: ParseErrorResponse only reads "code: X" when it starts its own "; "-delimited segment, so the prefixed form was silently parsing as no code at all.
|
Addressing the suppressed comment on Valid, and fixed in 88270ba. The codeless-403 branch led with "a role-binding write requires the superuser role" for every caller, and 10 of the 17 call sites are reads. An operator running The point the comment makes is sharper than "reword it", though: the three surfaces refuse for three different reasons, and no single sentence covers them without misleading someone.
So callers now pass the gate they went through, and the message is built from that plus the credential the client actually carried (
Two notes on the first review, for the record: the |
There was a problem hiding this comment.
🟡 Changes recommended
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
cmd/iam/user_role_revoke.go:60
- The guard that blocks
revoke USER adminwhen the user still holdssuperusertriggers even if the user does not actually hold a workspace-wideadminbinding. That breaks this command's own contract (“Revoking a role the user does not hold changes nothing and succeeds.”) for the (possible) half-state wheresuperuserexists butadminbinding has already been removed.
if role.Name == rbac.RoleAdmin && rbac.HoldsWorkspaceRole(bindings, rbac.RoleSuperuser) {
- Files reviewed: 27/27 changed files
- Comments generated: 1
- Review effort level: Lite
…e audit gate Two refusals said something the state did not support. The admin-while-superuser guard fired on the role name and the superuser binding alone, so a user holding superuser with no admin binding was refused a revoke of a row that does not exist - breaking this command's own promise that revoking an unheld role changes nothing and succeeds. The planned targets now decide: an empty plan reports nothing to do, and only a plan that would really delete a row reaches the guard. wouldStrandThePlatformFlags names the invariant and is tested over the four states. The codeless-403 message for an /api/rbac/ read said tokens are refused "reads included" and then immediately excepted the role history, which reads as a contradiction. The two are opposite failures, so they get their own gates: the audit log is the one route exempt from the Auth0 gate, where a token is accepted on every deployment and refused only for missing role_audit_log:read. Neither message now has to walk back what it just said.
|
Addressing the suppressed comment on Valid, and fixed in 86c6d88. The guard decided from the role name and the superuser binding alone: if role.Name == rbac.RoleAdmin && rbac.HoldsWorkspaceRole(bindings, rbac.RoleSuperuser) {so a user holding The half-state is reachable — the console or a raw DELETE can remove the admin row while superuser stands, which is exactly the state the guard exists to prevent this CLI from creating. Worth noting it is also the state left by an interrupted Precedence is now explicit: the planned targets are computed first, an empty plan reports nothing to do and exits 0, and only a plan that would really delete a row reaches the guard. targets := plannedRevocations(bindings, role.Name, cascade)
if len(targets) == 0 { /* nothing to do, exit 0 */ }
if wouldStrandThePlatformFlags(bindings, role.Name, targets) { /* refuse */ }
Verified against a stub seeded with that exact half-state: |
Splitting the audit-log gate in code left three prose sites asserting the blanket version: the README section, the 'user role' group help, and - by omission - the history command's own help, which never said it is the exception. The role audit log carries auth0_guard_exempt, so an API token reaches it on every deployment and is refused only for missing the role_audit_log:read scope. Every other /api/rbac/ route refuses the token itself on Alpacon Cloud. All three sites now say which is which, and history says it where an operator hitting the refusal will read it.
…e table 'user role history' promised rows "naming them as the subject or the actor". That describes the server's visibility rung, not what the command returns: it always sends ?user=, which pins the subject axis and intersects the actor half away. A grant you made to someone else appears in their history, and the help now says so. 'can-i -q' returns before the JSON branch, so -q --output json prints nothing. The help read as though the two combine; it now names dropping -q as the way to get a machine-readable answer. The exit-code table records the one script-visible behaviour change in this branch: 'alpacon user update' exits 1 when the edit touched a read-only privilege flag, where it previously exited 0 and printed success. It was documented in the command's own help but not where a wrapper's author would look. Also realigns the trailing comment on the 'user permission ls' line, which sat one column right of the rest of its block.
|
Eunyoung Jeong (@eunyoung14) All of it addressed. Taking the blocker first. The descriptionYou are right, and I checked the premise before acting: this repository is public and the repositories I was citing are not. The description carried private issue numbers, a handbook document by path and finding number, decision-record numbers with an open governance question and my own read of the control it covers, and server source paths with line numbers. I wrote that, and it should not have gone out. I am treating it as a disclosure that already happened, not one I have undone. GitHub keeps the body edit history and it stays visible to anyone who can see the PR, so the edit narrows further exposure and nothing more. If you want the exposure handled beyond that, say so and I will not touch it further in the meantime. The internal context is now in The public body is now what the diff shows: the commands, the CLI-side rationale, the behaviour worth knowing before merge, and the verification. I re-scanned it for every category you listed and it comes back clean. Correctness1. 2. 3. Token arms — fixed, Smaller items — all four, plus the wrapping
TestsClosed the gap you named — READMEBoth — CI is green on |
Jisung Chae (jisung-02)
left a comment
There was a problem hiding this comment.
Review at d6d4373
I checked the three commits that landed after the last review (07d112d, 0a1386a, d6d4373) against the diff, and every item raised there is addressed. This review covers only what is new on top of that.
What I ran locally, all clean: go build ./..., go vet ./..., golangci-lint run ./... (0 issues), go test ./api/... ./cmd/.... CI is green on all six checks. The new tests read no clock and no timezone, and no httptest handler carries an assertion, so -race -count=20 -shuffle=on and a TZ=Pacific/Kiritimati run both stayed stable.
Critical issues (0)
None.
Warnings (1)
1. cmd/iam/user_role.go:89-98 — naming yourself is not the self form.
resolveSubject takes the "-" alias only on the no-argument branch. Pass your own username or UUID and PK becomes the UUID, which is exactly the addressing gatePermissionIntrospect documents as refused: /api/iam/users/{id}/permissions/ pins no permission of its own, so a by-UUID read auto-resolves to an orphan user:permissions that only the superuser wildcard grants.
For a workspace admin without superuser:
alpacon user permission ls --patterns # works
alpacon user permission ls jane --patterns # 403, jane is the caller
alpacon user permission can-i server:update # works
alpacon user permission can-i jane server:update # 403, same account
The refusal message already says "run the command without a USER argument", so the case looks understood. It has the same shape as the permission_denied gap fixed in 07d112d, and it would be good to close it here too. resolveSubject already calls iam.GetCurrentUser on one branch, so comparing the resolved id against the caller's and swapping in selfUserPK would cover it.
TestResolveSubject has three cases and none is "the caller names themselves", so a fourth would keep it closed. The README also presents alpacon user permission ls <username> as the general form.
Suggestions (6)
1. cmd/iam/rbac_errors.go:77-78 — invalid_input is generic but rewritten into a write-only sentence.
Running the coded switch ahead of the gate is deliberate, and TestDescribeRBACError_CodedRefusalIgnoresTheGate pins it. That is right for rbac_superuser_last_removal_forbidden, a code only a write can produce. invalid_input is different: eight of the ten describeRBACError call sites are reads, and none of them binds a scope. can-i lets any argument containing : or * through looksLikePermission, so a malformed permission reaches the server, and a rejection there would print "the server rejected the binding scope" from a command that binds nothing.
Scoping the case to gateRoleWrite, or giving it the same kind of comment the duplicate case above it has, would settle it. workspace_suspended ("so it accepts no changes") reads the same way on user role ls.
2. cmd/iam/user_role_grant.go:58-61, cmd/iam/user_role_revoke.go:72-77 — --dry-run discloses less than the prompt it replaces.
The dry-run branch returns before both warnMissingReason and the confirmation. Previewing a superuser grant therefore never mentions the companion admin binding the server creates, which is the one fact the interactive path goes out of its way to state, and it never warns that the audit entry will carry no justification. Those two are exactly what someone reaches for --dry-run to see before a privileged write.
3. api/rbac/rbac.go:169, :194, :232 — nothing asserts the time columns.
BindingAttributesFrom, HolderAttributesFrom and AuditAttributesFrom all build their time string with AddedAt.Local().Format(timeLayout), and no test reads the resulting field. TestAuditAttributesFrom_ToleratesMissingActorAndRole checks ChangedBy, Role and Reason; TestHolderAttributesFrom_NilMapPrintsIDs checks User.
So a changed timeLayout, a dropped .Local(), or a row without added_at rendering as 0001-01-01 00:00 would all pass. The audit log exists to answer "who changed what, when", and the "when" is the unpinned part.
One fixed UTC instant asserted against the rendered string would cover it. Because of .Local() the expected value depends on the runner's timezone, so it needs either a pinned time.Local or a comparison on the time.Time itself. The current suite is stable precisely because it never touches timezones, and it would be good to keep that property while adding this.
4. api/iam/types.go:152-160 — the JSON tags on UserEdit and PrivilegeEdit are never used.
Neither struct is ever marshalled. cmd/iam/user_update.go:56 passes edit.Changes, a map, and PatchUser marshals that map. Every other type in this file is a wire type whose tags are live, so the next reader will take these four as evidence that the server accepts {"changes": ..., "privileges": ...}.
Leaving off the ~Response/~Request suffix reads as deliberate and I think it is right: these are not wire types. One small thing, PrivilegeEdit.Want is a thin name for a bool. Enable or Desired says what is wanted at the call site.
5. api/iam/user_edit_test.go — the filename points at a file that does not exist.
All three tests exercise diffEditedUser only, and diffEditedUser lives in api/iam/iam.go:358. There is no user_edit.go. Meanwhile api/iam/iam_test.go already covers other functions from the same source file (GetUserList, GetUserIDByName, CreateUser, InviteUser, AddMember), so one source file is split across two test files with nothing in the names marking the split. Merging into iam_test.go, or renaming to something like user_diff_test.go, would make it findable.
6. cmd/workspace/error_callbacks.go — the alias is now a shell worth removing.
Moving the body into api/mfa left this file as a single delegating line, while cmd/iam in the same PR calls mfa.WorkspaceErrorCallbacks directly. Two ways to reach one thing, and CLAUDE.md documents the split rather than removing it.
There are only two call sites (workspace_access_control_update.go:44, workspace_authentication_update.go:45), so the substitution is small. The part that needs a decision is cmd/workspace/error_callbacks_test.go, which calls errorCallbacks three times; that test now exercises api/mfa behavior, so moving it there would also put cmd/iam's path under the same coverage.
Related and much smaller: api/rbac/rbac.go:63. ResolveRole uses FetchAllPages to turn a name into a role, while the existing precedent for that operation, GetUserIDByName (api/iam/iam.go:217), does one SendGetRequest and takes Results[0]. The name filter is exact so behavior is identical, but two patterns now coexist for the same job. FetchPagesUpTo(..., 1) would put the intent in the code.
Out of scope for this branch (2)
Noting these only so they are on record; neither needs to happen here.
api/rbac/types.go:186— puttingIsWorkspaceWideandScopeLabelnext toUserRoleResponseis closer to ordinary Go practice, but CLAUDE.md's "Go declaration order" fixes top-level declarations atconst → var → type → func, and methods arefunc. The current placement follows the rule, so changing it is a repo-rule question rather than a file one.cmd/iam/json_slice.go— swapping a nil slice for an empty one keeps--output jsonemitting[]instead ofnull. List commands get this fromutils.PrintTable, but the JSON branches ofuser role describeanduser permission lshand-build amap[string]anyand skip that path, so the helper earns its place. If othercmd/packages hand-build JSON documents they are likely solving the same thing separately, which makes this a candidate forutilslater.
Highlights
- The tests pin contracts rather than exercise paths: one
JSONEqover the whole grant document, the revoke reason asserted absent from the query string,assert.NotContains(query, "ordering")for a parameter's absence, and a codeless-403 matrix carryingwantNotSaidper gate and credential so a message cannot quietly start giving the wrong advice. The two follow-ups held that bar, withTestGrantRole_ToleratesAnEmptyCreatedBodyandTestIAMHostedReadsHitTheRightPaths. rewrittenwithUnwrapis the right shape. The operator reads only the actionable half, andutils.HTTPStatusCodeanderrors.Isstill reach the server's refusal underneath.- Neither write verb claims success from a 2xx. Both re-read the bindings, which is what makes the superuser companion and a half-finished cascade visible instead of inferred.
plannedRevocationsrefusing to treat an admin-only account as a resumable cascade is a subtle call, and the comment explains why the obvious shortcut would strip admin from someone who was never a superuser.cmd/iam/member_add.go:43separating the group-membership--rolefrom the new RBAC workspace role in the help text, withTestRoleFlagKeepsOneMeaningkeeping--roleto one meaning repo-wide.utils.PrintHeaderwriting to stderr, so table mode leaves stdout parseable.
Checklist
- Code follows stack conventions
- Tests included and passing, verified locally and in CI
- No security vulnerabilities
- Documentation updated (README,
Longtext, CLAUDE.md, exit-code table)
utils.IsUUID accepts the un-dashed 32-hex form and either case, and resolveSubject stored whatever was typed. One consumer cannot cope: the audit log's ?user= is a plain CharFilter compared against a varchar holding the canonical dashed lowercase form, so a non-canonical value matched nothing and 'user role history' printed an empty table and exited 0 - indistinguishable from an account whose roles nobody ever changed, which is the worst answer an audit read can give. The binding list was unaffected; its filter coerces the value itself. Also corrects two comments that claimed more than the server does. They said the object permission check is what refuses a caller reading their own permissions by UUID. It does not: the user:owner role auto-granted per account carries 'user:*', which fnmatch-matches both user:read and the orphan user:permissions, and the server pins this with test_self_via_explicit_own_uuid_resolves_to_self. Sending '-' is still right - it is the server's own self route, one fewer check, and the form that still works for an account missing that binding - but it was never the difference between working and refused, and the earlier commit message and PR comment saying otherwise were wrong.
'user role history' printed the audit log's own "global" in a SCOPE column where every sibling command in the group prints "workspace", so one column header meant two vocabularies. Renamed at the projection. Four claims that no longer held: - The error-code block said all its codes come from the binding endpoints; permission_denied is the troubleshoot read refusing a non-caller target. - gatePermissionIntrospect said the orphan scope is satisfied only by the superuser wildcard. True of a cross-account read; a self read passes through the per-account user:owner role. - can-i's help said -q produces no output at all. A failed check still writes its line to stderr; only the answer is suppressed. - The diffEditedUser case named "clearing a flag" cleared nothing, because the fixture had both flags false. The fixture now sets is_superuser, the case actually clears it, and a new case covers a flag left at its current value - which is what the old one was really pinning. Three comments added by the comment-tidy pass used a spaced hyphen where the project uses an unspaced em dash.
|
Correcting something I told you in my last reply. I said The Sending I found this by running a second review pass over the delta since the first one, which is also where the rest of what follows came from. One real bug —
|
198 comment lines to 172. Two populations: the 63 lines the five commits after the first tidy added and never had judged, and an agreement pass over the 135 the first tidy kept, since those commits changed code underneath them. The new lines repeated the first batch's fault. canonicalUUID's doc spent seven lines on one pitfall, restating the empty-table-exit-0 clause twice. subject's nine carried a paragraph arguing against a claim its own previous version made - a correction aimed at the last reader rather than the next one. Four test comments duplicated a rationale that now lives at the site that owns it. One claim was simply wrong: a test comment said a rewrite swallowing the error code "would take the MFA and duplicate routing with it". It would not. HandleCommonErrors and IsDuplicateBinding both consume the raw error and the rewrite runs after them, so the assertion pins a forward contract, not a live path. The assertion stays; the reason is now honest. Two code defects the agreement pass surfaced are deliberately NOT fixed here, since this pass changes comments only: - gateAuditRead's two messages lead with the auditor reach limit, which the server applies as silent queryset narrowing and never as a 403; only a token missing role_audit_log:read can refuse there, which is what the gate's own comment still says. The bearer arm is unreachable besides - HasTokenScope returns True for a non-token credential - and a test pins its text. - auditScopeLabel maps global to workspace and passes content_type and object through raw, and EffectiveRoleAttributesFrom passes all three through, so the SCOPE column means three vocabularies rather than the one its comment promises.
Eunyoung Jeong (eunyoung14)
left a comment
There was a problem hiding this comment.
The description is cleaned and the context moved where it belongs — thank you. Every code item I raised is fixed, and two of the fixes are better than what I asked for. But the disclosure surface is wider than the description was, and one part of it becomes unfixable the moment this merges.
Where the content still sits
The description is clean. The only private-repo reference left is a bare issue number with no description, and the issue that now holds the context exists. That is exactly the shape I asked for.
Three other surfaces still carry the same categories, and none of them were covered by editing the body:
-
PR comments. Several comments on this PR carry the same classes the description did — internal authorization class names, an internal mechanic described by behavior, and a meta-comment enumerating which sibling repositories are private. One comment reproduces roughly forty words of a private repository's source file verbatim. That is the sharpest item currently live: it is internal source content, quoted literally, in a public thread. Comments are as public and as permanent as the body, and editing them leaves the same audit trail the body edit does.
-
Shipped source comments. Eight added comment lines across
cmd/iam/rbac_errors.go,cmd/iam/user_role.goandapi/iam/iam.goname a private repository's source path, a decision-record number, a server view class and method, an internal field class together with its database column type, and a deployment-conditional gate by name. These ship in the public tree permanently, and unlike a comment they are read by anyone who opens the file. -
Commit messages, and this is the time-critical one. Six of the twenty-four commits on this branch carry the same class of detail. After a merge commit they cannot be edited without a force-push. A squash merge with a rewritten subject and body collapses all twenty-four into one and is the cheap way out — but it has to be decided before the merge, not after.
I want to be clear about what is and is not achievable here. The pre-edit body remains retrievable through GitHub's edit history by anyone who can see the PR, so the cleanup narrows further exposure and does not undo what was already published. Your framing of it as a disclosure that already happened is the right one. What is still fully in our control is (1) not adding more, (2) the source comments, and (3) the commit messages.
For the source comments specifically: the useful half of each is the behavior the CLI has to accommodate. "The server narrows this silently rather than refusing" is worth writing down; naming the class that does it is what carries across the boundary. Rewriting them to describe the observable contract keeps everything a future maintainer of this repo actually needs.
Nothing else turned up — no internal hostnames, no customer names, no attack mechanics.
Code — all fixed, and two better than asked
permission_denied is mapped with a coded-403 test. role describe no longer pages the directory when a role has no holders. GrantRole returns an error and tolerates the empty 201 the bulk branch produces. describeRBACError now wraps, and the new rewritten type genuinely restores the chain — I checked that both utils.HTTPStatusCode and utils.ParseErrorResponse walk it with errors.Unwrap, so the status and the coded body survive a rewrite. The README exit-code row names the user update change.
Two I want to credit specifically:
user role history — narrowing the help rather than dropping the filter was the better call. The new wording ("the changes recorded against USER, so a grant you made to someone else appears in their history rather than yours") matches what the endpoint actually returns.
TestIAMHostedReadsHitTheRightPaths is exactly the guard the gap needed — a four-case table over all three IAM-hosted reads plus the concatenated scopes path, asserting the path and the query parameter, and failing if the permission field decodes to false. That closes the silent-wrong-denial case I was most worried about.
canonicalUUID is a real find on your own second pass: IsUUID accepts the un-dashed form while the audit filter is an exact string compare, so a typed UUID silently returned an empty table and exit 0.
One thing the fix traded
The gateAuditRead messages now lead with the auditor reach limit — but the server applies that limit as silent queryset narrowing and never returns a 403 for it. So both audit arms now lead with a cause that cannot produce the refusal they are describing, and by your own note the bearer arm is unreachable besides.
You documented both in the commit message and scoped this pass to comments only, which is a defensible line. I am flagging it because my item 6 asked you to fix an inaccurate message and the fix moved the inaccuracy one gate over rather than removing it. Worth a follow-up issue so it does not become the next reader's puzzle.
Before this can merge
There is a sixteen-thread review from jisung-02 with eight inline threads still unresolved and not outdated. Some are substantive — --dry-run returning before the reason warning and the companion-role disclosure, so the preview omits the two facts the interactive path exists to state; invalid_input rewriting to a binding-scope sentence on read call sites reachable from can-i with a malformed permission; and a test naming a file that does not exist. His first thread's mechanism is stale (a later commit corrected exactly that claim), but the observation under it still stands.
I am not going to merge over an open review from another reviewer. Please work those threads, and settle the commit-message question before the merge rather than after.
CI is green on 83e8c555. One correction to something I implied earlier: this repo runs a single ubuntu-latest leg for both Build and Test and golangci-lint, not a matrix.
…occur invalid_input reaches every call site on this surface, so it cannot claim the server rejected the binding scope; it now says a value was rejected without naming which one. The audit-log 403 no longer leads with the auditor reach limit. The server applies that limit as silent queryset narrowing—a short list, never a refusal—so a missing role_audit_log:read scope is the only cause a 403 there can have. The bearer arm of the same case was unreachable for the same reason and is gone.
…turns The superuser companion binding and the missing-justification warning printed after the dry-run branch had already returned, so --dry-run showed strictly less than the real run it exists to preview—and those two lines are what someone reaches for --dry-run to see. Both now print ahead of the plan, in grant and revoke alike; the confirmation prompt stays after it, where a dry run has no business reaching it.
The ?name= filter is exact, so the match is on the first page or nowhere. FetchAllPages kept asking for pages that could not contain it.
auditScopeLabel translated only "global", so an audit row at the other two tiers printed the server's raw "content_type" beside binding rows that say "type:42/web-01". It is now tierLabel, covering all three, and the effective-role projection—which prints the same vocabulary—routes through it too. The three rendered time columns went through .Local().Format() with nothing asserting the result, so a changed layout, a dropped .Local(), or a null added_at rendering as "0001-01-01 00:00" would all have passed. They now share localTimestamp, which blanks a zero time rather than printing year 1, and a test pins the layout, the zone conversion, and the blank against a fixed instant.
… what it sets Nothing marshals UserEdit or PrivilegeEdit—cmd/iam hands PatchUser the Changes map, which is marshalled on its own—so the json tags described a wire format that does not exist. PrivilegeEdit.Want is now Enable: the field holds the value the operator typed into the editor, and "enable" says which way it points where "want" only says someone wanted something. The test file is renamed to match the function it covers. user_edit_test.go pointed at a user_edit.go that has never existed; diffEditedUser lives in iam.go.
Moving the callback set into api/mfa left cmd/workspace with a one-line wrapper around it, while cmd/iam—added in the same branch—calls the exported helper directly. Two call sites now spell the same thing two ways, so the wrapper is gone and both update commands call the helper. Its test moves to api/mfa with it. The behavior it pins is api/mfa's, and from there it covers cmd/iam's callers too rather than only cmd/workspace's.
…nals Six comments added on this branch named a private repository's source path, a decision-record number, a server view class and method, an internal field class together with its column type, and a deployment-conditional gate by name. This repository is public and those ship in its tree permanently. Each now states the same thing as observable contract—what the server does that the CLI has to accommodate—which is the half a future maintainer of this repo needs. No behavior change; comments only.
api/iam/user_edit_test.go, cmd/workspace/error_callbacks.go and its test were deleted earlier on this branch—the first renamed to user_diff_test.go, the other two folded into api/mfa. A stale index entry put all three back into the previous commit, which broke the build: user_edit_test.go redeclares every test in user_diff_test.go and still refers to PrivilegeEdit.Want, and the workspace wrapper duplicates the helper its call sites now reach directly. Deleting them restores the tree the two commits intended. No other change.
Eunyoung Jeong (eunyoung14)
left a comment
There was a problem hiding this comment.
The code is done. I verified all four new fixes against the server's current main rather than against the commit messages, and every earlier item is still fixed.
be1bf47 is the one I want to credit. I asked you to fix a message that named a cause which cannot produce the refusal — and the fix removes the unreachable arm entirely rather than rewording it. I checked RoleAuditLogViewSet on the server: the auditor reach is applied wholly in get_queryset, so a non-privileged read returns rows and never a 403. Both old messages led with that, and both are gone. The test now asserts what is not said, which is the right shape for a message-accuracy guard.
95be0be puts both disclosures ahead of the --dry-run return on grant and revoke, with the confirmation prompt correctly still after. ee8c5ad is safe because the server declares name as an exact filter over a unique column, so one row is the whole answer. ecd45fb covers all three scope tiers the server emits and renders a zero timestamp blank; pinning the zone in the test rather than reading it was the right instinct.
7f255fd explains itself: the three files were deleted earlier on this branch and a stale index re-added them. Nothing from main was resurrected. Worth knowing that 39aa536 therefore does not compile — irrelevant under squash, relevant if anyone bisects.
All eight of Jisung Chae (@jisung-02)'s threads are answered in code, including the one where the rejection is correct — I confirmed against a server test that an own-UUID read resolves to self and is not refused. They still need a click to resolve; I am not going to merge over open threads from another reviewer.
Correcting my own advice, before the merge
I told you a squash merge with a rewritten body would collapse the commit messages. That is only true if the body is actually replaced, and this repository is configured so it is not. squash_merge_commit_message is COMMIT_MESSAGES, so the squash body is pre-filled with all 32 messages concatenated. Merging on that default would put them onto main permanently — the opposite of what I described. My mistake, and it would have caused the exact thing I asked you to avoid.
Ten of the 32 still carry the categories I flagged. So the merge itself is the decision point:
- merge with an explicit body —
gh pr merge 409 --squash --body "…", or - change the repository's squash-message setting to
PR_BODY(the body is clean) orBLANKfirst.
Either works. The default does not.
Note also that a correct squash keeps them out of main's history and nothing more — refs/pull/409/head stays fetchable regardless.
What is already published
The comment bodies are cleaned and the shipped source comments are fixed — six of six, and the replacements say the observable contract rather than naming what implements it, which is what a maintainer of this repo actually needs.
The earlier revisions of the edited text remain retrievable. That is not something an edit undoes, and for one of the surfaces it is not something you can remove at all. Your framing of it as a disclosure that already happened is right. I would rather work out how to handle it off this thread than describe it further here — a public post-mortem of where the material sits is one more pointer to it. Let's take that part to the server-side issue.
One thing to do here regardless: the italic notes you appended to the edited comments describe what the earlier version contained. Please strip those.
Not asking for these
Three comments in the diff use the server's framework and ORM vocabulary and name the identity provider. That vocabulary is already the baseline on this repo's main, in six places — one of which names a private repository's source file path outright. This PR is stricter than the branch it merges into. Holding it to a bar main does not meet would be the wrong call, so I am leaving them and will open a separate cleanup for the pre-existing one.
Approving on the code. Merge it once Jisung Chae (@jisung-02)'s threads are resolved, and with the squash body handled.
A tidy pass over every comment added or changed on this branch: 50 compressed, one deleted. Comments only—no executable line changes. What went: preambles, clauses restating the adjacent code or a field already visible in the struct, and the note on GrantRole explaining why a body it no longer decodes is discarded. What stayed, after a second pass argued against each proposed cut, are the clauses that carry a server behavior the CLI has to accommodate and that nothing else in the repo states: - the audit log's secondary sort key, which is what makes a paged --tail walk correct: a superuser grant writes two rows in one transaction, so equal timestamps are the normal case and the tie-break is why 100 rows come back distinct - that a binding list is narrowed to what the caller may see and answered 200, never 403, on GetUserBindings specifically—that slice feeds the "already holds" check, the revoke plan and the platform-flag guard, so a narrowed answer makes all three conclude absence - which rows the server's workspace-wide uniqueness actually covers, since IsWorkspaceWide deliberately counts a blank object id as unscoped too and so matches a pair that uniqueness never did - the exact, case-sensitive role name filter, on the exported function whose callers would otherwise normalize input and expect a match Two comments describing the platform tiers and the last-holder refusal also stayed: this is the only place either is written down outside an error string.
Jisung Chae (jisung-02)
left a comment
There was a problem hiding this comment.
Approving. Every point from my last round is answered, and the two I did not expect to be answered the way they were are the better outcomes: the timestamp comment turned up the zero-time rendering underneath it, and the permission_denied self-read was checked against the server rather than against my claim. The correction on that one is right and I withdraw it.
What I checked on this push: go test ./... is green across every package and go vet ./... is silent.
Two nits below, neither blocking.
Eunyoung Jeong (eunyoung14)
left a comment
There was a problem hiding this comment.
Re-approving on cdf6fbe66. My earlier approval was dismissed mechanically by dismiss_stale_reviews_on_push, not by anything substantive.
cdf6fbe is comment prose and nothing else. I checked it two ways rather than taking the subject line for it: every changed line's first non-whitespace token is //, and all sixteen files are byte-identical after comment stripping. No struct tag, string literal, identifier, signature or control flow moved — I looked specifically at the json:"..." tags in api/rbac/types.go and api/iam/types.go and at the user-facing strings in cmd/iam/rbac_errors.go, since those are where a "comment-only" pass most often isn't.
It also removes six more internal-detail clauses from shipped comments, on top of the six 39aa536 had already cleaned. That was your own initiative, not something I asked for.
Thank you for working Jisung Chae (@jisung-02)'s threads to resolution rather than leaving them answered-but-open — and he has now approved this exact head.
Two things stand between this and the merge button
Two new threads arrived with his approval — the test fixture carrying a character the next line strips, and the tier label spelled two ways between two commands' output. He flagged both as nits he would not hold the PR for, but this repo's ruleset sets required_review_thread_resolution, so an unresolved thread is what is holding mergeStateStatus at BLOCKED regardless of severity. Answer or resolve them and the button clears.
The squash body still needs a hand edit. squash_merge_commit_message is COMMIT_MESSAGES, so the body is pre-filled with all 33 messages concatenated. Eight of them carry the categories I flagged — and that count is unchanged rather than improved, which is expected and not a criticism: the previously-audited commits are SHA-identical, so no message could have changed. The only fixes are replacing the body at merge time or rewriting history, and replacing the body is the cheap one.
Separately, and fixable right now: the PR body's first line carries a private-repo cross-reference. That is on the public page today, independent of the squash. Worth editing before this merges.
Context that is not this PR's problem
While checking the shipped comments I looked at what main already carries, so the bar here is clear. main ships this same category widely — roughly two dozen lines naming the private server repo, thirteen backend source paths, and a couple of dozen decision-record references, including in thirteen of the files this branch touches. One instance is in the public README on main, in the exit-code table, and it arrived with a different PR.
This branch neither adds to that nor removes it, and it is now stricter than the branch it merges into. That cleanup is real but it is a separate, larger piece of work and should not be hung on this PR. I will open it separately.
Nothing in the 2974 added lines across 47 files reintroduces any of the six categories — I read all 174 added Go comment lines.
CI is green on cdf6fbe66, and the required build-and-test context is present. Being nine commits behind main is harmless here: the ruleset's strict policy is off, and because this branch does not touch .github/ at all, the merge ref picks up main's fixed workflow — which is also why the flat context reports correctly despite this branch's own copy predating that change.
The content-type tier had two spellings in one binary: tierLabel printed "content-type" while ScopeLabel prints "type:42" and "type:42/web-01" for the same tier, so 'user role ls' and 'user role history' named it two ways side by side in an operator's terminal. tierLabel now returns "type", which heads the string ScopeLabel builds, and its test says the two must agree so the next change to either has to keep them in step. The un-dashed case in the canonicalization test was written as 33 characters with a space, which the loop then stripped back out to reach the 32 the case is about. It is now the 32 characters directly, and the stripping line and the strings import are gone.
|
Filed the cleanup I mentioned: #414. It confirms the scope is |
dc668e6
Jisung Chae (jisung-02)
left a comment
There was a problem hiding this comment.
Approving. Both nits from the last round are fixed in dc668e6, and the tier one came back better than I asked for.
tierLabel returns type, which is the string ScopeLabel heads its own output with, so user role ls and user role history name the tier the same way in the same terminal. TestTierLabel now carries the reason in one line, so the next change to either function has to keep the pair in step instead of rediscovering why they match. The UUID fixture is the 32 characters directly, and the ReplaceAll line and the strings import went with it.
Verified locally on dc668e6: golangci-lint run ./... reports 0 issues., and go test -race ./... passes with no FAIL, api/rbac and cmd/iam included. All six CI checks are green.
Nothing blocking left. One label that is not worth a commit on its own: the test case at api/rbac/rbac_test.go:88 is still named "content-type wide", the last place the old spelling survives. It never reaches an operator, so fold it into whatever touches that file next.
Closes #270. Context: alpacax/alpacon-server#3324
What this changes
is_staffandis_superuseron a user are read-only projections of the account's RBAC roles: the server drops them from aPATCHand answers200with the flags unchanged, never an error. The CLI had no way to change a privilege, andalpacon user updatereported success for edits that were never applied — it round-tripped the whole user detail body through$EDITORand PATCHed it back, flags included.alpacon user updatenow builds a sparse patch of only the fields the editor actually changed. A privilege-flag edit is held back and reported with the exactalpacon user rolecommand that performs it, and such an edit never exits 0 — whether or not the rest of it applied. The sparse patch also stopsis_ldap_userbeing re-submitted on every edit, which made the server run a live LDAP lookup for a field nobody touched.alpacon user create's Staff and Superuser prompts are unchanged. The create path is a different contract and writes the role rows correctly.Why this command shape
Surveyed kubectl, Teleport (
tsh/tctl), AWS/gcloud/Azure IAM, HashiCorp Boundary/Vault/Consul, Auth0/Okta/1Password/GitHub/Snowflake, plus clig.dev, the Heroku style guide, the kubectl sig-cli conventions and the gcloud/Azure CLI guidelines.Counted across every tool, the surface hangs off the role noun 6 to 3. But those six are cloud IaaS CLIs whose principals are usually machines. Restricted to tools whose subject is a human in an identity provider or SaaS workspace, the convention inverts: Auth0 ships
auth0 users roles assignand Teleport puts the account first. This API binds exactly one principal kind, so gcloud's typed--member=user:prefix and kubectl's repeatable--user/--group/--serviceaccountwould be ceremony here.grant/revokeGRANT ROLE analyst TO USER jane). TheTO/FROMflip keeps word order identical.can-i-q. The subject is optional, so one argument asks about yourself.cataloglsis already the binding list on the same group.describe ROLENo new top-level noun, no
cmd/root.gochange, and no new--roleflag —--roleoccurs exactly once incmd/today (cmd/iam/member_add.go, the group-membership tier), so after this it still means one thing binary-wide. A test walks the wholeUserCmdtree to keep it that way.Decisions the survey settled:
--cascadeis opt-in. The one rule the survey is unanimous on: breadth is added only on the revoke side and must be typed (gcloud's--allonremove-iam-policy-binding, absent fromadd-). Never delete a row the operator did not name.--reasonis optional, with a warning at the platform tier. A justification flag is idiomatic; a required one is not — gcloud PAM brackets[--justification]in its flagship JIT product, Teleport defers requiredness to server-side policy, and NIST SP 800-53 Rev.5 AC-6(9) demands logging, not operator-typed justification.user role historyis what keeps it from being write-only decoration.setverb. Boundary can shipset-principalsonly because its server takes the whole desired set in one request; the atomicity is the server's. A client-side diff over a non-transactional API has none of it, and asetwhose diff computes an unmentioned DELETE demotes a colleague as a side effect of an incomplete command line.BindingCreateRequestalready carries the scope pair, so adding a flag later is purely additive.Behaviour worth knowing before merge
superusercreates a companionadminbinding server-side, so revokingsuperuserdemotes to admin and leaves that companion;--cascaderemoves both, superuser first. With no superuser binding to start from,--cascaderevokes nothing rather than reaching for the companion.adminfrom someone who still holdssuperuseris refused before anything is sent — the account would keep every platform flag while no longer registering as an admin. Revoking a role a user does not hold is never refused.alpacon user rolecommands, so they need a browser login;user role historyand theuser permissioncommands are the carve-outs, and the README says which.alpacon user updateexits 1 when the edit touched a privilege flag. That is the only behaviour change a wrapper script would notice, and it is in the exit-code table.Verification
go build ./...,go test -race ./...,go vet ./...,gofmt -l .— all clean; CI green.user updateregressions, the--cascadeguard, the superuser companion, the last-holder refusals, the self-form request paths and the-qexit codes.JSONEqover the whole document, the revoke reason never reaching the query string, the absence of anorderingparameter, the multi-page walk, the IAM-hosted read paths, and the full refusal-message matrix per gate and credential with negative assertions.1.Commits
18 commits, each building on its own (checked by replaying each into a scratch worktree), plus the review round. Read-only surface first, then the write verbs, then the
user updatefix, then permission introspection, then tests and docs.🤖 Generated with Claude Code