Bite 30D — Self‐Service and Delegated Authorization Refactor - RodrigoMattosoSilveira/EnterpriseRemoteSystems GitHub Wiki
Issue: Bite #30D — Self-Service and Delegated Authorization Refactor
Branch: 708-bite-30d-self-service-and-delegated-authorization-refactor
- Migration
000048_self_service_delegated_authorization_refactorapplied successfully. -
make local-checkpasses. - Go tests pass.
- Frontend tests pass.
- Playwright E2E tests pass.
Bite 30D changes the authorization model from:
Authentication Account
↓
Actor
↓
PERSON Role Grant
↓
self-service permissions
to:
Authentication Account
↓
Tenant Actor
↓
ACTIVE Person–Tenant Membership
↓
Global Person
↓
intrinsic self-service permissions
Delegated Roles are now additive:
intrinsic self-service permissions
+
delegated Role permissions
=
effective permissions
Examples of delegated tenant Roles include:
TENANT_ADMIN
EARNINGS_OPERATOR
EXPENSE_OPERATOR
The promotion tests must therefore prove four major things:
- A Person no longer needs a
PERSONRole Grant to use self-service. - Collaborator self-service is derived intrinsically from current Collaborator state.
- Delegated Roles add authority without replacing or manufacturing self identity.
- Application-level authorization administration remains separate from tenant authority.
Do not treat the following as Bite 30D promotion blockers unless 30D itself regressed previously working behavior.
Final Account-owned tenant selection/session switching belongs to Bite 30E.
Do not require final tenant-switching UX or cache/session behavior beyond what already worked before 30D.
Final removal of Application Administrator standing access to tenant business data and Tenant Support Access Leases belongs to Bite 30H.
For 30D, verify that Application authorization administration is application-scoped and that tenant Roles do not manufacture application authority.
Do not require the final 30H Application Administrator tenant-data isolation model yet.
Prepare or identify the following disposable test identities.
A Person with:
Authentication Account: ACTIVE
Person–Tenant Membership: ACTIVE
Collaborator Journey: none
Delegated Roles: none
Record:
Login:
Person ID:
Actor ID:
Tenant:
This is the most important 30D identity.
A Person with:
Authentication Account: ACTIVE
Person–Tenant Membership: ACTIVE
Collaborator Journey: ACTIVE/current
Delegated Roles: none
Record:
Login:
Person ID:
Collaborator ID:
Actor ID:
Tenant:
A tenant-bound Actor with:
Person–Tenant Membership: ACTIVE
Delegated Role: TENANT_ADMIN
Record:
Login:
Actor ID:
Tenant:
A tenant-bound Actor with:
Delegated Role: EARNINGS_OPERATOR
A tenant-bound Actor with:
Delegated Role: EXPENSE_OPERATOR
Use the normal application-global administrator.
Expected:
scope = APPLICATION
roleCodes includes APPLICATION_ADMIN
Identity A — Person-only Account.
- Sign in normally.
- Allow the application to reach the Person's first permitted page.
- Open the Person's own record.
- Open any existing self-service history that the Person is entitled to see.
- Login succeeds.
- The Person can access their own Person record.
- Previously allowed Person self-service remains available.
- No authorization error occurs merely because there is no
PERSONRole Grant. - The Actor is tenant scoped.
- No delegated Role is required for basic Person self-service.
Promotion blocker: Person self-service disappears unless a PERSON Role is granted.
While signed in as Identity A, run:
fetch("/api/v1/authz/current-actor", {
credentials: "include",
headers: {
"X-Tenant-ID": "<CURRENT-TENANT-ID>"
}
})
.then(r => r.json())
.then(body => console.log(JSON.stringify(body, null, 2)));The response should demonstrate the 30D separation.
Verify:
-
scopeis tenant scoped. - The Person identity is present/resolved.
-
roleCodesdoes not need to containPERSON. -
intrinsicPermissionscontains the Person's self-service permissions. -
delegatedPermissionsis empty, or contains only genuinely delegated permissions. -
permissionscontains the effective union. - Basic self-service works even when
roleCodesis empty.
Record:
roleCodes: : null,
intrinsicPermissions: : [
"authz.self.read",
"people.self.read",
"people.self.update",
"reference_data.read",
"tenants.read"
],
delegatedPermissions: []
permissions: [
"authz.self.read",
"people.self.read",
"people.self.update",
"reference_data.read",
"tenants.read"
],
For Identity A, inspect the Actor's Role Grants.
Example:
sqlite3 -header -column backend/data/app.db "
SELECT
a.actor_key,
r.code AS role_code,
g.tenant_id,
g.active
FROM authz_actors a
LEFT JOIN authz_actor_role_grants g
ON g.actor_id = a.id
LEFT JOIN authz_roles r
ON r.id = g.role_id
WHERE a.id = '<ACTOR-ID>'
ORDER BY r.code;
"- No active
PERSONRole Grant exists. - No active
SELFRole Grant exists. - Person self-service still works.
If a historical inactive PERSON grant remains:
- It is inactive.
- It is not required for authorization.
Use a disposable Person with:
ACTIVE Membership
Authentication: Not enabled
- Sign in as that tenant's Tenant Administrator.
- Enable Authentication.
- Complete the first-login password flow.
- Sign in as the newly provisioned Person.
- Verify their own Person page is accessible.
- [ x Inspect their Role Grants.
- Authentication Account is created/reused normally.
- Person self-service works.
- No active
PERSONRole Grant is created. - No login-time backfill recreates the
PERSONRole Grant.
Promotion blocker: provisioning or first login recreates PERSON as an authorization dependency.
Using Identity A:
- Sign out.
- Sign in again.
- Refresh the application.
- Inspect the Actor's Role Grants again.
- No
PERSONRole Grant appears. - Self-service still works.
- Authorization remains stable across login/logout.
Use a disposable Person Account whose current tenant Membership can safely be deactivated for testing.
Record the Account and Actor first.
- Confirm the Account can initially use Person self-service.
- Deactivate the Person–Tenant Membership using the supported administrative workflow.
- Attempt to use the current tenant again.
- The inactive Membership no longer establishes intrinsic self-service for that tenant.
- ERS does not fall back to an old
PERSONRole Grant. - ERS does not silently borrow another tenant's Membership or Actor.
- No tenant data is exposed through the now-invalid identity chain.
Restore the Membership after the test if needed.
Promotion blocker: an inactive Membership still produces intrinsic self-service.
After Test 9:
- Reactivate the Person–Tenant Membership.
- Establish a fresh authenticated session if necessary.
- Open the Person's self-service page again.
- Person self-service is restored.
- No
PERSONRole Grant is created. - The same global Person and tenant Actor continue to be used.
Identity B — Collaborator Account with no delegated Roles.
- Sign in.
- Open the Person's own record.
- Open the Collaborator's current work assignment/current-work area.
- Open current earnings/expenses/current-account functions that are part of Collaborator self-service.
- Person self-service is available.
- Current Collaborator self-service is additionally available.
- No
PERSONRole Grant is needed. - No operator Role such as
EARNINGS_OPERATORorEXPENSE_OPERATORis required for self-service views. - The Collaborator cannot administer unrelated tenant data merely because they are a Collaborator.
Run /api/v1/authz/current-actor as Identity B.
- Collaborator self-service permissions appear under
intrinsicPermissions. - Operator/admin permissions do not appear unless actually delegated.
-
roleCodesdoes not need a Collaborator/self-service Role. -
permissionsis the union of intrinsic plus any delegated permissions.
Use a disposable Collaborator.
- Confirm current Collaborator self-service works.
- End/discontinue the current Collaborator Journey using the normal administrative workflow.
- Establish a fresh session if necessary.
- Revisit current Collaborator functions.
- Person self-service remains.
- Current Collaborator-only self-service is no longer available.
- [ x Historical Person access remains according to existing rules.
- ERS does not require deleting the Authentication Account or Person Actor.
- No Role Grant must be manually revoked merely to end Collaborator self-service.
Promotion blocker: ended Collaborator status still grants current Collaborator intrinsic authority.
Identity C — Tenant Administrator.
- Open the administrator's own Person/self-service record.
- Perform a normal Tenant Administrator operation.
- Query
/api/v1/authz/current-actor.
- Person intrinsic permissions are present.
-
TENANT_ADMINappears inroleCodes. - Tenant-admin permissions appear under
delegatedPermissions. - Effective
permissionscontains both intrinsic and delegated permissions. - Delegated administration does not replace the administrator's own Person identity.
Conceptually:
Person intrinsic permissions
+
TENANT_ADMIN delegated permissions
=
effective Tenant Administrator authority
Identity D — Earnings Operator.
- Own Person self-service works.
- Earnings operator functions work.
- Unrelated Expenses administration remains unavailable unless separately delegated.
- Authorization administration remains unavailable.
-
EARNINGS_OPERATORis delegated, not intrinsic.
Identity E — Expenses Operator.
- Own Person self-service works.
- Expense operator functions work.
- Unrelated Earnings administration remains unavailable unless separately delegated.
- Authorization administration remains unavailable.
-
EXPENSE_OPERATORis delegated, not intrinsic.
Use a disposable tenant-bound Actor with a delegated operator Role.
- Confirm self-service works.
- Confirm the delegated operator function works.
- Revoke the delegated Role.
- Establish a fresh session if necessary.
- Retry both functions.
- Delegated operator function is no longer available.
- Person self-service still works.
- Person identity remains intact.
- Authentication Account remains intact.
- No
PERSONRole is created as a fallback.
Promotion blocker: revoking an operator/admin Role also destroys ordinary Person self-service.
Application Administrator using /admin/authorization.
Use an Actor that is:
bound to an Authentication Account
+
bound to an ACTIVE Membership
+
bound to the same target tenant
- Select a valid tenant Role such as
TENANT_ADMIN,EARNINGS_OPERATOR, orEXPENSE_OPERATOR. - Grant it for the Actor's tenant.
- Grant succeeds.
- Grant appears active.
- The Role becomes effective for that tenant.
- No duplicate or malformed grant is created.
Use or create an authorization Actor that is not bound through an Authentication Account to an active Membership in the target tenant.
Attempt to grant:
TENANT_ADMIN
or another tenant Role.
Preferably the UI prevents the operation or explains the requirement.
If submitted directly:
- Backend rejects the grant cleanly.
- No malformed Role Grant is persisted.
- No 500 error occurs.
Expected conceptual rule:
tenant Role
requires
Actor bound to that same tenant
Promotion blocker: detached Actors can acquire tenant delegated authority.
Use an Actor bound to Tenant A.
Attempt to grant a tenant Role scoped to Tenant B.
- Operation is rejected.
- Actor does not acquire Tenant-B authority.
- Existing Tenant-A identity remains unchanged.
- No cross-tenant grant is persisted.
Promotion blocker: Actor bound to Tenant A can receive a Tenant-B delegated Role.
Application Administrator on /admin/authorization.
Inspect available Roles for a new grant.
-
PERSONis absent from grantable Roles, disabled, or clearly historical/inactive. -
SELFis not grantable. - A direct API attempt to grant either Role is rejected.
- Existing historical inactive records may remain for audit/migration history.
Promotion blocker: administrators can recreate PERSON or SELF as active delegated Roles.
Inspect /admin/authorization and/or the database.
- Legacy
PERSONRole is inactive. - Historical grants are inactive.
- Historical records were not unnecessarily destroyed if retained for audit/history.
- They do not affect effective permissions.
This validates an important 30D security invariant.
Use a tenant Actor that has delegated authority but does not represent the target Person.
Attempt to access another Person's self-only resource using:
- a delegated Role;
- or any effective permission that happens to have the same permission name as a self permission.
- Request is denied unless the resource actually belongs to the authenticated Person identity.
- ERS validates Person identity in addition to permission.
- Delegated authority cannot turn another Person's resource into "self".
Promotion blocker: possessing a delegated/effective permission is sufficient to impersonate another Person's self-service identity.
Using Identity A:
- Access the Person's own self-service resource.
- Request succeeds.
- Success comes from intrinsic identity/permission.
- No delegated Role is required.
Together Tests 23 and 24 establish:
self authorization
=
correct intrinsic permission
+
correct authenticated Person identity
not merely:
self authorization
=
permission string exists somewhere
Identity F — Application Administrator.
Open:
/admin/authorization
Perform safe operations such as:
- list Actors;
- list Roles;
- inspect Role Grants;
- create/revoke a disposable valid delegated tenant Role Grant.
- Authorization Administration loads.
- Application Administrator can perform supported control-plane operations.
- Current Actor remains application scoped.
Check:
fetch("/api/v1/authz/current-actor", {
credentials: "include",
headers: {
"X-Tenant-ID": "default"
}
})
.then(r => r.json())
.then(body => console.log(JSON.stringify(body, null, 2)));Expected approximately:
scope = APPLICATION
roleCodes includes APPLICATION_ADMIN
Identity C — Tenant Administrator.
Attempt to open:
/admin/authorization
and, if practical, directly call an authorization-administration API.
- Application Authorization Administration is unavailable or denied.
- Direct API request is rejected with
403or equivalent. - Tenant Administrator authority does not become Application scope.
- Tenant Administrator cannot manage the global Role/permission catalog.
Promotion blocker: TENANT_ADMIN can administer application-global authorization.
As Identity C, run /api/v1/authz/current-actor.
scope = TENANT
roleCodes includes TENANT_ADMIN
Verify:
- Actor is tenant scoped.
-
TENANT_ADMINappears as delegated authority. - Application control-plane permissions are absent.
Use a disposable Person with active Membership and no Authentication Account.
- Enable Authentication.
- Complete required password change.
- Sign in.
- Authentication works.
- Correct tenant Actor resolves.
- Person self-service works.
- No active
PERSONRole Grant is created.
Use an Account already bound to two tenant Actors.
Resolve current Actor for Tenant A and Tenant B using the existing supported mechanism.
- Same Authentication Account remains in use.
- Tenant A resolves Actor A.
- Tenant B resolves Actor B.
- 30D did not duplicate Accounts or Actors.
- Intrinsic permissions are derived from the Membership associated with the resolved Actor.
Do not require final Bite 30E tenant-switching UX behavior.
Use a disposable Account.
- Record Actor bindings and delegated Roles.
- Deactivate the Account.
- Reactivate it through the supported 30C.2 recovery workflow.
- Sign in again.
- Same Account remains.
- Same Actors remain.
- Same valid delegated Role Grants remain.
- No
PERSONRole is recreated. - Intrinsic self-service returns when the valid Account/Actor/Membership identity chain is restored.
During Tests 4–30:
- Keep DevTools Console open.
- Review failed Network requests.
- No unexpected JavaScript exceptions.
- No unexpected
500. - Expected authorization failures return clean
401/403. - Invalid tenant Role Grants return validation/authorization errors rather than database crashes.
- No cross-tenant authority leak occurs.
After manual testing, review the backend log for unexpected:
panic
internal API error
foreign key
constraint failed
role grant
authorization
intrinsic
delegated
- No panic.
- No unexpected internal errors.
- No unexpected grant-scope constraint failures during valid operations.
- Deliberately invalid grant tests fail cleanly.
Run:
sqlite3 backend/data/app.db "
PRAGMA foreign_key_check;
"No rows returned.
Run:
sqlite3 -header -column backend/data/app.db "
SELECT
id,
code,
name,
active
FROM authz_roles
WHERE UPPER(code) IN ('PERSON', 'SELF');
"Any legacy PERSON/SELF role that remains:
active = 0
Run:
sqlite3 -header -column backend/data/app.db "
SELECT
g.id,
g.actor_id,
r.code AS role_code,
g.tenant_id,
g.active
FROM authz_actor_role_grants g
JOIN authz_roles r
ON r.id = g.role_id
WHERE UPPER(r.code) IN ('PERSON', 'SELF')
AND g.active = 1;
"No rows returned.
Run:
sqlite3 -header -column backend/data/app.db "
SELECT
g.id AS grant_id,
g.actor_id,
r.code AS role_code,
g.tenant_id AS grant_tenant,
aaa.tenant_id AS actor_binding_tenant
FROM authz_actor_role_grants g
JOIN authz_roles r
ON r.id = g.role_id
LEFT JOIN auth_account_actors aaa
ON aaa.actor_id = g.actor_id
AND aaa.scope_type = 'TENANT'
AND aaa.tenant_id = g.tenant_id
WHERE g.active = 1
AND g.tenant_id <> '*'
AND UPPER(r.code) NOT IN ('PERSON', 'SELF')
AND aaa.actor_id IS NULL;
"No rows returned.
Any row here would indicate an active tenant delegated Role Grant without a corresponding same-tenant Actor binding.
Bite 30D is ready for promotion when all of the following are true:
- Person-only Account can use self-service with no active
PERSONRole Grant. -
current-actorclearly separates intrinsic, delegated, and effective permissions. - New Authentication provisioning does not create a
PERSONRole Grant. - Login does not backfill a
PERSONRole Grant. - ACTIVE Person–Tenant Membership is required for intrinsic Person self-service.
- Membership reactivation restores intrinsic Person self-service without creating
PERSON. - Current Collaborator self-service is intrinsic.
- Ending Collaborator status removes current Collaborator-only intrinsic authority while preserving Person self-service.
-
TENANT_ADMINis additive delegated authority. -
EARNINGS_OPERATORis additive delegated authority. -
EXPENSE_OPERATORis additive delegated authority. - Revoking a delegated Role does not remove ordinary Person self-service.
- Valid tenant Role Grant succeeds only for an Actor bound to that same tenant.
- Detached Actor cannot acquire tenant delegated authority.
- Actor bound to Tenant A cannot acquire a Tenant-B delegated Role.
-
PERSONandSELFcannot be newly granted. - Existing historical
PERSON/SELFgrants are inactive. - Delegated permissions cannot spoof another Person's self identity.
- Own self routes work from intrinsic authorization.
- Application Administrator can administer application authorization.
- Tenant Administrator cannot administer application-global authorization.
- Tenant Administrator remains tenant scoped.
- Existing Authentication provisioning still works.
- Existing multi-tenant Authentication Account behavior still works.
- Account deactivation/reactivation preserves Actor/delegated-role bindings without recreating
PERSON. - No unexpected
500, panic, or cross-tenant authorization leak occurs. -
PRAGMA foreign_key_checkreturns no rows. - No active
PERSON/SELFRole Grants remain. - No active tenant Role Grant exists without a matching same-tenant Actor binding.
Do not promote Bite 30D if any of these occur:
- A Person loses self-service merely because no
PERSONRole Grant exists. - Authentication provisioning or login recreates an active
PERSONRole Grant. - An inactive Person–Tenant Membership still yields intrinsic self-service.
- Collaborator current self-service requires an operator/admin Role.
- An ended Collaborator Journey continues to provide current Collaborator intrinsic authority.
- Revoking an operator/admin Role also destroys ordinary Person self-service.
- A detached Actor can receive a tenant Role.
- A Tenant-A Actor can receive a Tenant-B Role Grant.
-
PERSONorSELFcan be newly granted. - A delegated permission can impersonate another Person's self-service identity.
- A Tenant Administrator can administer the application-global authorization catalog.
- A tenant Actor resolves as Application scope.
- 30D creates duplicate Accounts or Actors.
- Account recovery recreates
PERSONgrants. - Any cross-tenant authorization leak is observed.
- Any unexpected database integrity violation, panic, or
500occurs.
If all promotion-gate items pass, Bite 30D — Self-Service and Delegated Authorization Refactor is ready for promotion.