Skip to content

feat(core): a module that declares a page declares roles like a solution - #59

Merged
antoinetoussaint-byte merged 3 commits into
mainfrom
feat/module-page-declares-roles
Oct 30, 2026
Merged

antoinetoussaint-byte merged 3 commits into
mainfrom
feat/module-page-declares-roles

Conversation

@antoinetoussaint-byte

@antoinetoussaint-byte antoinetoussaint-byte commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Owner's direction (2026-10-21, standing)

People get roles on what they actually use: a solution or a module page. Each of these can have several roles, and an installer is always one of them. A bare module (one with no page) declares scope ceilings only, and a person's access to it still comes from their organisation roles. The handbook proposal of 2026-10-30 (obin-ai/handbook#428) says a module that declares a page declares installer/editor/viewer like a solution does, and installation roles are the single vocabulary that gates portal pages. The host tried to accept module roles in module-saas-starter#1276 and found that the core refuses them, so the change starts here, in the host-independent core.

The rule chosen, and the "has a page" signal

  • Signal: core's signed module manifest already carries Frontend (solutionhost.ModuleManifest.Frontend). Core documents it as the page a component contributes, and when it is present it must include exposed_module. delivered.FromCore now sets domain.Module.Page = manifest.Frontend != nil. The field is tagged json:"-": it is read only while the module is applied, decisions key on Roles, the solution review digest does not change, and a store does not need to keep it.
  • Manifest decision (decide.ValidateModule): a module may declare roles only when it declares a page. Its roles then follow the same rules as a solution's: it must have an installer role (ErrNoInstallerRole), every role must stay within what it serves or consumes, and no role may select an owner. If a module declares roles but has no page, the core refuses it by name. Installation-owned roles stay solution-only. A module page that declares no roles is accepted and keeps today's behaviour.
  • decide.RoleGated(m) = solution, or a module that declares roles.
  • Issuance (held()): for a role-gated module, a person holds the scopes of the roles granted on its organisation's installation, intersected with what the module serves. This works exactly as for a solution, except that the scopes select no owner. A module installation has no principal, and the scopes are no broader than the organisation roles they replace. Organisation roles stop applying there, and a person with no role is refused with holds no role on <module>.
  • decide.Narrowed: moving a module between organisation roles and its own roles, in either direction, counts as a narrowing, as does cutting one of its roles. Capabilities that were already issued then end.

Unchanged (fail-closed)

  • A bare module still gets its organisation roles.
  • A role name that is not declared is refused (declares no role), and a module with no roles has nothing grantable.
  • installation_role and none work as before.
  • Solutions, installation-owned roles, owner-aware rules and the review digest are untouched.
  • A call to a module page through a solution is still bounded by the solution's roles alone, as it is for any module today.
  • The local permission report now lists a module page's installation roles.

Tests

  • decide/module_page_test.go: covers validation (accepted / no page / no installer / beyond what it serves / owned roles / no roles), owner-blind RoleScopes, and the narrowing matrix.
  • authority/module_page_test.go: the role holder gets exactly the role. Another member, the admin and a member whose org role covers the kind all get holds no role. An undeclared role is refused, and a revoke ends access. After a redelivery without roles, the earlier capability is refused and org roles decide again. After roles are declared again, the org-role capability is refused. A module with roles and no page is refused.
  • delivered: a manifest that declares a frontend is read as a page.
  • Conformance modulePageRoles (both hosts): the holder is entitled, ViewerToken is refused, an undeclared role is refused, a module with roles and no page is refused, and a redelivery without roles hands control back to org roles. The local testkit declares the two delivery workloads. Accounts' fixture must do the same before it adopts this suite version.
  • I checked that the new tests fail when the change is reverted: with held() back to Kind == solution the holder is refused, and without the gating comparison in Narrowed the org-role capability outlives the change.
  • gofmt, go vet ./... and go test -race -count=1 ./... are clean in every Go module of the repo. The local authority passes the full conformance suite. Postgres-backed hop tests run only when HOP_TEST_POSTGRES is set, and it was not set locally.

Classification

Fix at the owning place: the host-independent core decides. The local authority and Accounts stay thin wrappers. The only local edits are the permission report and the testkit's workload list.

Review status

  • Reviewed: a fresh-context security bug-hunter reviewed head 2e22605 and found no blocker (verdict: merge). It confirmed:
    • the page signal is part of core's signed manifest and is read after verification;
    • tenancy holds, because role grants are filtered by installation and organisation;
    • a mode switch revokes capabilities already issued, in both directions.
  • Finding 1, fixed in 56e4fbe:
    • ListResources at a module page that declares roles now answers from the roles granted on its installation, the same way issuance does (check.go:131-155). Before, a viewer with no organisation role passed Check but got an empty list.
    • Tests: a core unit test (the holder lists every note, an id-scoped org role does not narrow the list, and a revoked holder is refused) and conformance assertions (the holder lists by role; a revoked holder lists nothing).
  • Finding 5, fixed in 56e4fbe: the conformance case now shows organisation roles applying again after a redelivery without roles. The module also serves ReadKind, so ViewerToken is refused while roles are declared and is issued ReadKind read once the roles are gone.
  • Findings 2-4, filed as follow-ups:
    • 2: old role grants come back when a module switches modes again (roles, then none, then roles);
    • 3: a switch to roles locks out every member until an org admin grants roles;
    • 4: roles on consumed kinds are accepted but have no effect.
  • Not run:
    • Codex is out of credits, so no Codex review.
    • Accounts has not run the new conformance case (module-saas-starter#1276). Its fixture must declare the conformance-page-module and conformance-headless-roles delivery workloads.

🤖 Generated with Claude Code

antoinetoussaint-byte and others added 2 commits October 30, 2026 09:46
A module whose signed core manifest declares a frontend (a page) may declare
roles in its authority document, held to the solution's rules: an installer
role, every role within what it serves or consumes. What a person holds at
such a module is the roles granted them on its organization's installation,
owner-blind (a module installation has no principal); organization roles no
longer apply there. A module declaring no roles keeps the organization's
roles; a module declaring roles and no page is refused. Moving a module
between organization roles and its own is a narrowing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The suite delivers a module declaring a page with installer and viewer roles:
the viewer's holder is issued exactly its scope, another member holds nothing
whatever the organization grants, an undeclared role is refused, a module
declaring roles and no page is refused, and redelivered without roles the
organization's roles decide again. The local testkit declares the two
delivery workloads.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@antoinetoussaint-byte

Copy link
Copy Markdown
Collaborator Author

Review: authority#59 at 2e22605

go test ./... passes in authority-core and in the local authority. No test is weaker: carrier_test.go:144 still matches, because its substring is inside the new refusal message.

Findings, ranked

  1. ListResources uses organisation roles at a module page that declares roles. Follow-up, major. It fails closed and widens nothing. At check.go:131-140, solutionCeiling skips non-solutions (issue.go:78), so the answer comes from orgRoles. Example: maya holds viewer and no organisation role. Check passes, but ListResources returns []. A person with id-scoped organisation roles gets a listing filtered by those ids. The capability still caps the answer. Fix before a module page lists resources.

  2. Old grants come back across mode switches. Follow-up. Gen1 has roles and maya is granted viewer. Gen2 drops the roles. Gen3 redefines viewer as read+write, and maya holds it with no admin action. Narrowed (manifest.go:126) only detects cuts, the same gap solutions have. There is also no review surface for module roles (consent.go:104).

  3. Switching to roles locks out every member. Follow-up. Module installations have no installer grant (lifecycle.go:109-125), so access returns only when an org admin grants roles. Fail-closed.

  4. Roles on consumed kinds are accepted but do nothing. Follow-up. ValidateModule accepts them (manifest.go:73-74). held intersects with what the module serves (issue.go:56), and children ignore module roles (issue.go:78). Refuse them or document it.

  5. Conformance never shows organisation roles applying again after redelivery without roles, only refusals. Follow-up. Accounts main pins core and conformance at f82a98e, runs conformance.Run, and builds modules through coredelivered.Module, so Page flows there. After a bump the new case will fail loudly until accounts' fixture declares the two workloads. That is visible, not silent drift, but saas has not run the case yet.

Checked and sound

  • Page signal. Frontend is part of core's signed canonical manifest (solutionhost/manifest.go:611) and is read after VerifyDelivered (plugin.go:103). Keeping it out of the digest (json:"-") is correct: decisions key on Roles, and modules have no consent step.
  • Tenancy. HeldRoles filters on the installation id. installationOf looks up by the person's organisation. ChangeRole requires the subject to be in the installation's organisation.
  • Mode switch. A switch counts as Narrowed and moves the installation revision (lifecycle.go:77), so old capabilities are refused in both directions.
  • Edge cases. Duplicate or unnamed roles are refused (delivered.go:162). An empty list means no roles. A missing installer is refused. OwnedRoles is refused on modules. Children reach other modules only through held at the target, never through page roles.

VERDICT: merge. There is no tenancy, widening or fail-open defect; every finding fails closed, and item 1 must land before a module page uses ListResources.

…issuance

ListResources fell back to organization roles for any person's capability
not through a solution, so a module page's viewer holding no organization
role was checked but listed nothing, and an id-scoped organization role
narrowed the listing. A module page declaring roles now answers from the
roles granted on its installation, as held() does for issuance. The
conformance case asserts the holder's listing, a revoked holder's refusal,
and organization roles deciding again once the module withdraws its roles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte merged commit a61acf9 into main Oct 30, 2026
17 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