Repository navigation
feat(core): a module that declares a page declares roles like a solution - #59
Conversation
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>
Review: authority#59 at 2e22605
Findings, ranked
Checked and sound
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>
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
installeris 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
Frontend(solutionhost.ModuleManifest.Frontend). Core documents it as the page a component contributes, and when it is present it must includeexposed_module.delivered.FromCorenow setsdomain.Module.Page = manifest.Frontend != nil. The field is taggedjson:"-": it is read only while the module is applied, decisions key onRoles, the solution review digest does not change, and a store does not need to keep it.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 aninstallerrole (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.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 withholds 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)
declares no role), and a module with no roles has nothing grantable.installation_roleandnonework as before.Tests
decide/module_page_test.go: covers validation (accepted / no page / no installer / beyond what it serves / owned roles / no roles), owner-blindRoleScopes, 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 getholds 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.modulePageRoles(both hosts): the holder is entitled,ViewerTokenis 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.held()back toKind == solutionthe holder is refused, and without the gating comparison inNarrowedthe org-role capability outlives the change.gofmt,go vet ./...andgo 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 whenHOP_TEST_POSTGRESis 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
ListResourcesat 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.ReadKind, soViewerTokenis refused while roles are declared and is issuedReadKindread once the roles are gone.conformance-page-moduleandconformance-headless-rolesdelivery workloads.🤖 Generated with Claude Code