ποΈ PUT-1708 + PUT-1709 + PUT-1743: TeamController, teams_enabled, and the isolation suite - #3714
Conversation
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
cef8d34 to
4f9dafd
Compare
4f9dafd to
d1fe0a7
Compare
d1fe0a7 to
4cfb67c
Compare
f8a5120 to
9d6c97e
Compare
9d6c97e to
66a4984
Compare
66a4984 to
457e996
Compare
457e996 to
6645730
Compare
6645730 to
75103bb
Compare
40da279 to
6c19992
Compare
6c19992 to
e548b8d
Compare
e548b8d to
1577f8b
Compare
1577f8b to
e01c3eb
Compare
e01c3eb to
3b20199
Compare
3b20199 to
5dc7aac
Compare
5dc7aac to
ad8f7a5
Compare
ad8f7a5 to
41c88f4
Compare
41c88f4 to
5f6f322
Compare
Local validationEngine: mysql 8 Β· full teams stack Β· verified session actors The flag genuinely unregisters the routes. With
404 β 401 purely from config, which also confirms the value reaches the Isolation: an outsider cannot distinguish a real workspace from a nonexistent Byte-identical, so the endpoint is not an existence oracle. Mutations behave the Audit endpoint returns the real trail, newest first, with actor attribution: Owner CRUD β One follow-up tracked in PUT-1745, not here: the credential-reissue route is not |
5f6f322 to
33f593f
Compare
β¦uite Covers PUT-1708, PUT-1709 and PUT-1743. Twelve routes, every one setting requireUserActor -- that option is what installs requireAuthGate, requireVerifiedAccount and requireNonAccessTokenGate, because server.ts derives `needsAuth` from the route options. Reads need it as much as writes: without an auth option a route gets no suspension check and admits access tokens, so a just-disabled member could still read the roster and a scoped third-party token could read the audit log. Authority is checked before anything observable. Validating the body first made POST /members answer 400 before 403, and resolving :username first turned the member routes into a global username-existence oracle. Provisioning applies the same username and email rules as signup rather than its own -- USERNAME_REGEX, USERNAME_MAX_LENGTH, RESERVED_USERNAMES and validator.isEmail, now exported from AuthController. Without them a workspace could mint accounts signup would refuse, claim unregistered reserved names, and mail arbitrary unvalidated addresses. Handle problems are 400 or 409 rather than a bare Error, which the server turns into a 500 and a deduped critical alarm -- an uppercase handle should not page on-call. Disable drops sessions through SessionStore.removeByUuid rather than a raw DELETE. The store invalidates every composite cache key; without that a disabled member kept authenticating from cache for the session TTL, which is exactly the "takes effect on the next request, not after a cache TTL" property disable is supposed to have. Revoking also preserves last_ip/last_user_agent, which the member-facing audit view reads. Audit writes live in TeamService at the point of each action rather than in the route, so a caller reaching the service directly cannot skip them, and the SQL lives in TeamStore. Audit reads map internal user ids to usernames, and remain readable by the owner after the workspace is soft-deleted -- otherwise the delete_team entry was written and immediately unreachable. teams_enabled gates route registration through an optional isEnabled() the server honours, so with it off the paths do not exist rather than existing and refusing. It does not gate DDL. TeamIsolation.http.test.ts asserts the negative the feature rests on: the workspace manages accounts and cannot read them, including through a full-access token and after the member is disabled. It asserts outcomes rather than the absence of an implicator.
33f593f to
9bc2cd7
Compare
Covers PUT-1708 (controller + audit writes), PUT-1709 (
teams_enabled) and PUT-1743 (the isolation regression suite). Grouped because 2.7 cannot pass until the surface exists, and 2.8 gates exactly what 2.6 adds.The routes
Twelve, under
/teams, all on theapisubdomain.POST/GET/teamsGET/teams/:uidPUT/DELETE/teams/:uidGET/POST/teams/:uid/membersPOSTβ¦/members/:username/activationPOSTβ¦/members/:username/disableand/enableGET/teams/:uid/auditGET/teams/:uid/audit/meEvery route sets
requireUserActor: true. That option is what makesserver.tsinstallrequireAuthGate,requireVerifiedAccountandrequireNonAccessTokenGateβneedsAuthis derived from the route options, so a route that sets none gets no auth middleware at all even thoughreq.actoris populated globally. Reads need it as much as writes: the suspension check lives inrequireAuthGate, and without it a just-disabled member would still read the roster and a scoped access token would reach the audit log.gates.tsis explicit that account management stays closed to every access token, which isrequireUserActorGate's job rather thanrequireNonAccessTokenGate's β the latter admits full-access PATs and never inspectsactor.app.Authority is checked before anything observable. Two orderings matter:
requireOwner, so an unauthorized caller cannot learn whether their request was well-formed by comparing 400 against 403.:usernameis resolved afterrequireOwner, so the member routes are not a global username-existence oracle.A non-member gets 404 rather than 403 so the endpoint does not confirm a workspace exists, and the workspace owner is refused as the target of every member route via
org_owned.Layering: the controller touches no database
Audit writes live in
TeamService, at the point of each action βprovisionAccount,disableMember,enableMember,deleteWorkspaceβ rather than in the route handlers. A caller reaching the service directly (a driver, an extension, a cron job) therefore cannot skip them, which matters for a table whose whole purpose is accountability under a single administrator. The SQL lives inTeamStore:appendAudit,listAudit,listAuditForUser,countPayers.Audit reads map internal user ids to usernames before responding, the same way
toClientTeamstripsteam.id, and stay readable by the owner after the workspace is soft-deleted β otherwise thedelete_teamentry would be written and immediately unreachable, since the normal lookup filtersdeleted_at IS NULL.Provisioning reuses signup's validation
USERNAME_REGEX,USERNAME_MAX_LENGTH,RESERVED_USERNAMESandvalidator.isEmail, now exported fromAuthControllerrather than reimplemented. A workspace must not be able to mint accounts signup itself would refuse β the username becomes the/usernamehome-directory segment β nor claim unregistered reserved names, nor send activation mail to unvalidated addresses.Handle problems surface as 400 (unusable) or 409 (taken), including the unique-index race. A bare
Errorhere would reachserver.tsas a 500 and a dedupedcriticalalarm, so an uppercase handle would page on-call.Disable takes effect on the next request
disableMemberdrops sessions throughSessionStore.removeByUuidrather than a rawDELETE. The store invalidates every composite cache key with its double-delete pattern; without that, a disabled member keeps authenticating from the session cache for its TTL β which is exactly the "next authenticated request, not after a cache TTL" property PUT-1707 relies on.Going through the store also revokes rather than deletes, so
last_ipandlast_user_agentsurvive. PUT-1746's member-facing audit view reads those to make administrative use of a reset visible to the account it was performed on; a hard delete would remove that evidence.The feature flag
teams_enabledgates route registration, through an optionalisEnabled()that#registerControllerRouteshonours. With it off the paths do not exist rather than existing and refusing, and no team code is reachable. It does not gate DDL β the tables ship either way and the feature is inert, which is what lets phases 1β3 be live in production before a workspace can be created. It is also the backout from phase 4 on: turning it off removes the feature without touching data.The isolation suite
TeamIsolation.http.test.tsasserts the negative the whole feature rests on: a workspace manages accounts and cannot read them. Negatives do not defend themselves β nobody writes that grant deliberately, but a new implicator or a widened actor would create it and no other test would fail./teams/:uid/files,β¦/members/:username/files,β¦/kvThe full-access token case is called out separately because
#scanAccessTokenresolves such a token by re-scanning the issuer as a plain user actor β the path a future implicator would be inherited through.The suite asserts outcomes, never the absence of an implicator: a test checking that none is registered would pass while the access existed by another route. The last row is the control that keeps the refusals from being vacuous β it refuses before the grant and succeeds after, inside one test, so it cannot pass by the endpoint being broken in either direction.
Verification
Both HTTP suites run against a real server on both engines. Worth knowing for anyone adding to them: a failed
beforeAllis reported by vitest as skipped, not failed, so a broken fixture makes a security suite silently stop running while CI stays green. Two dialect-specific fixture bugs were caught that way βis_diris a realbooleanon postgres, andpragma_table_infois sqlite-only.Known gaps
listMembersin the same store is keyset-paginated; the audit should matchappendAuditthrows, the account is already suspended and signed out with no record of itinvalidateActorSubscription, theouter.gui.*pushCloses PUT-1708, PUT-1709 and PUT-1743.