Skip to content

feat(staff): gestao de equipe via API, permissoes RBAC e rotas staff - #27

Merged
ArtRiv merged 5 commits into
mainfrom
feat/staff-management
Oct 6, 2026
Merged

ArtRiv merged 5 commits into
mainfrom
feat/staff-management

Conversation

@ArtRiv

@ArtRiv ArtRiv commented Oct 6, 2026

Copy link
Copy Markdown
Owner

O que este PR introduz

  • Adiciona a permissao \staff.manage\ ao catalogo e restringe inicialmente ao papel de administrador.
  • Quatro novas rotas administrativas em /staff\ para gerenciamento de acesso sem necessidade de comandos SQL diretos:
    • \GET /staff\ (listagem de usuarios de equipe)
    • \PATCH /staff/:userId/role\ (alteracao de papel)
    • \POST /staff/:userId/permissions\ (concessao de permissao avulsa)
    • \DELETE /staff/:userId/permissions/:permissionId\ (revogacao de permissao avulsa)
  • Testes unitarios e E2E garantindo revogacao imediata e protecao de rotas.
  • Especificacao detalhada em \docs/specs/staff-management.md.

ArtRiv and others added 5 commits September 2, 2026 16:57
The five hard questions answered before any code: the last-administrator
guard counts holders of the permission rather than roles; granting
staff.manage is granting everything, written down instead of half-contained;
staff is a non-default role or any direct grant, with exact-email lookup as
the only way in; nobody gives themselves anything, but renouncing is allowed
so the guard is reachable at all; and 404 stays where an id is in the path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fifteenth permission, and the only one that hands out the other
fourteen: whoever holds it can move an account to admin. It goes to admin
by derivation (Object.values), and the operator list is left untouched on
purpose — operators run the store, they do not hire for it.

The migration carries it into databases that already exist. Reference data
still comes from the code through prisma/seed.ts and is not copied here;
what a running database cannot learn from a changed constant is that
admin's role_permissions needs a fifteenth row. Without it the feature
ships unreachable: nobody holds staff.manage, and the only route that
could grant it requires staff.manage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
List the team, change a role, grant and revoke a per-user permission —
all gated on staff.manage. The case that forced it: an operator reads the
catalogue and cannot create a product, so the person hired to catalogue
pieces is none of the three roles.

The design is mostly its refusals (docs/specs/staff-management.md):

- Nobody gives themselves anything. Granting to yourself and changing your
  own role are refused outright; a role change adds as easily as it
  removes, so it is not a decision anybody makes about themselves.
- Renouncing your own grant IS allowed, because giving up access is never
  an escalation — and because it is the one path that can reach the guard
  below, which would otherwise be untestable.
- The store never loses its last administrator. The count is over
  effective holders of staff.manage, role permissions united with per-user
  grants, not over who has the admin role: an operator carrying the grant
  administers exactly as much as an admin. Locked FOR UPDATE before the
  write, ORDER BY id, so two simultaneous renunciations queue instead of
  both reading "somebody else is still here" and both committing.

The listing is staff only — a non-default role or any direct grant — with
exact-email lookup as the way to find whoever is about to be promoted,
since by definition they are not staff yet. Substring search would be
customer enumeration and does not exist. Responses are written column by
column: User carries passwordHash and googleId.

Lives in auth because users, roles and user_permissions are auth's tables;
a module of its own would be a second boundary crossing, this one writing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Thirty-six cases against a real Postgres, covering what a mock cannot:

- the whole hiring flow — promote a shopper, grant products.create, and
  the account creates a piece with the token it already held, while
  orders.refund stays 403 because it was never given;
- revoking, and the very next request with that same token is 403. No new
  token, no waiting fifteen minutes for the old one to expire;
- the last-administrator guard, twice, both times with NO account holding
  the admin role: one holder renouncing is refused, two holders means the
  first may go. A guard that counted admins would answer wrongly in both,
  which is the point of counting holders;
- the listing recorte, which is a where clause and needs rows to exist;
- that no response carries passwordHash or googleId.

Granting answers 200 rather than Nest's default 201: the response is the
account, which already existed, and a repeat grant creates nothing at all.
Same reading the order transitions already take. openapi.json regenerated
— 42 paths, 50 operations, and the staff 403s name staff.manage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README's promote-by-SQL is now only the bootstrap — there is nobody to
ask for the first admin, since the route that grants requires the
permission nobody has yet. Everything after that is /staff, and the
Limitações section says what remains: no e-mail invite, no account
lifecycle, and staff.manage not being contained.

admin-api.md item 4 closes. Two of the three rules it demanded shipped as
written; the third — "refuse to grant what the caller does not have" —
did not, and the entry says why: it contains nothing while role changes
exist, because whoever can move an account to admin grants the whole
catalogue without touching the grant route. Half a containment reads as a
limit that is not there, so the consequence is written down instead.

security.md gains the two entries this feature owes a reader: the account
enumeration exception (GET /staff?email= answers whether an address has an
account — to a caller who already holds full administrative power, and
only on exact equality), and what actually limits an insider once
delegation is delegable.

Also: the module map records why the routes live in auth rather than in a
module of their own, the storefront contract gets the shape of a team
screen and its four refusals, and the new-store runbook gets the curl
calls that replace the second UPDATE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ArtRiv
ArtRiv merged commit ef96ad2 into main Oct 6, 2026
2 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.

1 participant