Repository navigation
Link several roles from one grant, and name every role by id - #124
Conversation
A grant now links any number of roles and holds its own entries beside them; what it confers is the union. Roles are named by id everywhere a name used to travel — mapping rules, a provider's default roles, an activity's enrollment sets, the built-ins. OIDC writes one read-only grant per provider at system scope. LTI maps a platform's roles to an installation role or to the activity's enrollment set, and a launch adds what is missing and removes nothing; a role taken away by hand is kept as a tombstone so no launch brings it back. `role:manage` is global and administrator-only; `role:manage:activity` is new, scoped, and what lets a manager write their own activity's roles. The excess rule is applied to what a caller adds, never to what is already there. Closes the nineteen defects the end-to-end run of 2026-09-19 found, including two privilege-escalation paths, each with a regression test.
An upgraded installation carries copies until their holders next sign in, and that sign-in only wrote links. The copy would have outlived every mapping change.
The rules existed and were seeded, and nothing could read or change them. A platform's are the only ones that may aim at an activity's enrollment sets.
An activity's enrollment roles were projected from a navigation nothing includes, so every read answered an empty list while the rows sat in the table — and the test covering it asserted the list was empty. Deleting an activity's role asked for the global key. A write that said nothing about roles computed the union from nothing, so the staff flag collapsed and the last administrator was refused a save that took nothing. Three readers still saw a grant's own entries and not its roles; the account-deletion hold is the one that mattered. Each has a regression test, and each test was sabotaged to prove it bites.
A review round, after CI went greenEvery check on this pull request passed on the first push. A review of the diff afterwards found four defects none of them could see, and they are fixed in the commits above with a regression test each — every test sabotaged to prove it bites. An activity's enrollment roles never reached the wire. Deleting an activity's role asked for A write that said nothing about roles computed the union from nothing. "Absent leaves the roles alone" reached the stored links but not the union every rule beside them reads, so saving a grant without naming its roles recomputed the staff flag from an empty set: a jury member linking Three readers still saw a grant's own entries and not its roles. Four more, smaller, from the same round: the enrollment-role excess rule read
|
Both apply, as a union: a narrow rule adds to a broad one rather than replacing it. Nothing asserted it, and the page said the opposite.
Implements
A_GRANT_LINKS_SEVERAL_ROLES_2026-09-19. This breaks the contract, which 0.2 is allowed to do.A grant links any number of roles and holds its own entries beside them; what it confers is the union. Every role is named by its id — mapping rules, a provider's default roles, an activity's enrollment sets, the built-ins, which are found by a new
BuiltInKeyso a renamed shipped role keeps working.Why, and what it fixes
An end-to-end run against a real Keycloak and a real Moodle 5.2.2 found nineteen defects in the model of 2026-09-13, two of them privilege escalations: a manager whose grant sat at system scope — which is what a directory group hands out — could edit the shipped roles and empty
admin; and an activity's role could be named where an installation role was expected. The 209 tests then in this repository saw none of them. Every one has a regression test here.The model
GrantRoles, one row per linked role, carryingSourceProviderId(who added it) andDismissedAt. The tombstone exists for one case: an LTI launch adds the roles its rules name and never removes one, so without it a manager's correction would be undone at that person's next launch.Roles.BuiltInKey, filtered-unique and global-only, so every enrollment path resolves a shipped role by key rather than by name.IdentityProviderMappingRulesname a role by id and carry a target — a role, or the activity's participant or manager enrollment set. Only a platform's rules may use the last two.IdentityProviders.Kindseparates a sign-in provider from the row an LTI platform speaks through, so a platform no longer appears in the providers list.LtiBoundaryTestsstill holds: nothing outsideLti/names LTI.ActivityEnrollmentRolesreplaces the two role columns. Absent means unchanged, an empty list clears.role:manageis global and administrator-only;role:manage:activityis new, scoped, and in themanagerrole. The catalog is 55 keys, the manager role 37.Two defects found while building this, which no gate would have caught
A contribution the upgrade leaves as a copy would have outlived every mapping change. Nothing recorded which claim values produced a contribution written before this change, so the migration cannot turn one into links. The sign-in that rewrites it only wrote links, so the stale copy would have kept granting what it held for as long as the account existed. It now empties the row's own entries too. Found by reading what the migration did to a development database holding real data from the end-to-end run, and pinned by a test that fails when the fix is reverted.
A role link added only through a navigation is tracked
Modified, notAdded. Every model here assigns its own key in the constructor, so EF issued anUPDATEfor a row that was never inserted, zero rows were affected, and a second LTI launch answered409 concurrency.conflicton a grant nobody else had touched. It affected every addition of a role to an existing grant, in the launch path and in the panel. Four call sites now write to the set as well as to the collection.The migration
20260919220105_aGrantLinksSeveralRoles, hand-ordered: schema additions, then nine data steps, then the drops, then the constraints. The data steps rewriterole:managein three cases, setBuiltInKey, link the grants whose set still equals the role they were made from, carrySourceProviderIdonto the new links, move mapping rules from names to ids (deleting the unresolvable), and fill the enrollment tables.Downcollapses the links back and folds several intoPermissions, which is a widening and says so in a comment.Rehearsed against a development database carrying the end-to-end run's data: 33 grants, 22 links, 12 roles, 6 rules, and every activity grant's copy correctly recognized and linked.
Measured
dotnet test: 981 passed, 0 failed.dotnet build -warnaserror: 0 warnings.openapi.jsonregenerated from the container, twice — the second time after the platform mapping-rule surface.Also here
An operator can now read and write a platform's role rules. They existed and were seeded, and nothing could reach them — so "which LTI role means which role here is configuration" was true only in the database.
Not covered
Nothing re-derives a provider contribution the migration left as a copy; it is corrected at that person's next sign-in, and the release note has to say so. Roles accumulate, because nothing subtracts — pruning a role a rule no longer names is manual. Revoking a grant is not a ban: the next launch enrolls that person again.