Permissions
The string form, the two scopes, the resolution rules, the three shipped roles, and all 55 keys.
The Server is what enforces authorization, so the whole vocabulary lives in one
place: AlgoJudge.Server/Authorization/Permissions.cs. The Client fetches it
rather than hard-coding it, so an installation that adds an entry does not need
a Client release to show it.
The form
resource:operation[:qualifier]A permission is a string, not a column. A schema that enumerated these in columns could not express one that did not exist when the migration was written.
own versus all is baked into the operation rather than evaluated as a
separate ownership test. That covers the two cases the product has and is
deliberately not general: "everyone in my group" would need a new operation, not
a different predicate.
The two scopes
A grant holds a set of these strings and is scoped:
system-wide (ActivityId null) or to one activity. The same string means the
same thing at either scope; what changes is how far it reaches.
| Scope column below | Where the key is meaningful |
|---|---|
global | in a system grant only |
activity | in an activity grant only |
both | in either — in a system grant it means "in every activity" |
Resolution
In this order. The order is the decision.
- An activity grant carrying
OverrideSystemis authoritative inside its activity, and nothing else applies there — not evensystem:administrator. Checked first, because checking the administrator bypass before it is exactly how the override would be lost. system:administrator, held in any system contribution, bypasses every other permission check at every other scope. It does not put its holder in an activity, and submitting asks that separately.- Otherwise the effective set is the union: every system contribution, plus the activity grant when one activity is asked about.
- What one grant carries is itself a union — every role it links, and any entries of its own beside them. Either half may be empty, and a grant may link as many roles as it is given.
- There is no subtraction anywhere in the model.
- Nobody may grant, or map onto, a permission they do not themselves hold. Enforced where a grant is written — and where a role is written, since a grant links one — never where it is read. It is applied to what a caller adds: taking a permission away is never refused.
There is no deny list to reach for
"A manager everywhere, except in this contest where I compete" is
OverrideSystem — an activity grant that replaces the system contributions
wholesale — and never a permission taken away. A model with denies has to say
which of two contributions wins where they disagree, and every answer to that is
somebody's surprise.
An Invited grant confers nothing. A grant whose Permissions column does not
parse grants nothing either — the alternatives being to lock every user out of
every screen, or to treat a corrupt row as an administrator.
System scope is several rows
A user holds one system contribution per source: the manual one, plus one per linked identity provider. They union. This is the one place the model is additive.
Two answers a caller must not conflate
The Server's "which activities is this permission held in" query returns null for not restricted and a list for these activities. An empty list is a third answer and means none.
A panel list asked without an activity narrows instead of refusing
An activity named in a query is a question at that scope, and the key is required there — no key in that activity, no list. No activity is a different question, everything I may read, and the answer to that one is a narrowing.
So a manager whose only grant is on one course opens the panel's unfiltered submissions list and is shown that course. Asking at system scope instead refuses them, because a grant on an activity contributes nothing to a question asked with no activity in it — and that is every manager an activity's grant editor makes.
Holding the key nowhere is still a refusal. An empty page would tell somebody who may not look that there is nothing to see, which is a different sentence and the wrong one.
Four of the panel's lists resolve their scope this way — submissions
(submission:read:all), questions (question:read:all), grants
(grant:read:all) and printouts (printout:manage). The rule lives in one
place rather than in each list: a security rule written four times is a security
rule that drifts three times.
An activity that withheld the key from itself drops out of the narrowing. An
activity grant carrying OverrideSystem is the whole answer inside its
activity, so one that does not carry the permission withholds it however widely
it is held elsewhere — including from somebody who holds it system-wide.
Some surfaces ask only *anywhere*
The problem library is the installation's rather than one activity's and is
still a manager's work, so it answers to problem:read:own held anywhere
and then shows what that person owns, what the installation shares, and what is
shared with them. problem:create, problem:update, problem:archive,
problem:share and the enrollment picker's user:read:all ask the same way.
Managing one activity is what admits somebody to them; asked at system scope
they refuse every manager whose grant is on an activity.
Listing the installation's accounts and reading somebody's sessions are
administration and still ask at system scope. /manager/users is not a
manager's screen.
The three shipped roles
A grant links roles — any number of them — and what it confers is their union together with its own entries. Editing a role changes what everybody holding it may do, at once, including people enrolled long before the edit.
Several rather than one, because the two paths that decide what somebody holds both speak in sets: a claim may match several mapping rules, and a launch from a course platform may carry several roles. A union is commutative, so there is no ordering to decide and no precedence to explain.
An edit reaches everybody at once
This is a live link, so a role is worth treating as one. The panel shows how many grants an edit would reach before you save it, an activity's own role reaches only that activity, and nobody may put into a role a permission they do not themselves hold.
A provider owns which roles its contribution links
An identity provider's mapping rule names roles, and the contribution it writes links them like any other grant — so editing one of those roles reaches its holders at once, exactly as it does anywhere else. What the provider owns is which roles are linked: that set is rewritten from the rules at every sign-in, so editing a rule, or moving somebody between groups at the directory, reaches them the next time they sign in.
A role belongs either to the installation or to one activity. An activity's
role may only be granted inside it, and is written by whoever holds
role:manage:activity there — which is how a manager corrects what their own
participants may do without touching what every participant in the installation
may do. Writing the installation's roles is role:manage, which is global and
an administrator's.
participant — 8 keys
activity:read submission:read:own submission:create result:read:own
question:read:own question:create ranking:read printout:requestThis list is load-bearing beyond the grant editor: it is what the staff test measures against, and therefore what decides who appears in a ranking.
manager — 37 keys
Everything a participant has, plus:
activity:update activity:archive activity:enroll
problem:read:own problem:create problem:update
problem:share problem:archive problem:attach
submission:read:all submission:source:read:all
submission:rejudge submission:cancel submission:exclude
result:read:all result:log:read:all
question:read:all question:answer question:publish
announcement:create
printout:manage
ranking:read:unfrozen ranking:unfreeze
user:read:all user:create:temporary
grant:read:all grant:update
role:read role:manage:activityThe other eighteen are deliberately not included: activity:create,
activity:delete, trial:run, problem:read:all, problem:delete,
problem:import:external, the user: keys that create and edit accounts, the
four runner: keys, instance:update, provider:manage, role:manage and
system:administrator.
A manager runs an activity; they do not run the installation it lives in.
role:read and role:manage:activity are in the set. The second is scoped:
held in an activity grant it writes that activity's roles and nothing else.
Writing the installation's roles is role:manage, which is global — a role the
installation shares is reachable from every activity, so writing one is an
installation-wide act.
admin — 1 key
system:administratorOne entry, because it bypasses the rest. An administrator with a list of individual permissions is an administrator who can be quietly trimmed.
Staff, and the ranking
A grant is systemic — out of the participant count and out of the ranking — when it carries any permission an ordinary participant does not hold. The Server computes that on every write and never accepts it from the caller.
Two flags are kept per catalog entry rather than one, because they are two questions: does the participant role grant it and does holding it make somebody staff. They differ for exactly one key.
`trial:run` is held without becoming staff
It is outside the participant role, because a trial spends a Runner and opening it is a manager's decision in one activity. It is not staff-conferring, because spending your own time on your own package is not seeing or changing other people's work.
Inferring one flag from the other would have emptied a whole activity's board the moment somebody was allowed to time their own code.
A key the catalog does not describe counts as staff. An unknown right is more likely to be a new one somebody has been given than an ordinary participant's, and guessing the other way would quietly put them in the ranking.
The catalog — 55 keys
Activity
| Permission | Scope | Means |
|---|---|---|
activity:read | activity | see the activity and its series at all |
activity:create | global | create activities |
activity:update | both | settings, series, dates, freeze |
activity:archive | both | close an activity: still readable, nothing new accepted |
activity:delete | both | destroy an activity and everything in it |
activity:enroll | both | add and remove members |
trial:run | both | ask for a package to be run without it being anybody's problem |
Problems
The library is private by default; these say what a manager may do with a problem they can already see. Which problems those are is decided by ownership and the share list.
| Permission | Scope | Means |
|---|---|---|
problem:read:own | global | see problems they own or that are shared with them |
problem:read:all | global | see every problem in the installation, sharing ignored |
problem:create | global | add a problem to the library |
problem:update | global | edit statement, package, configuration, publish a version |
problem:delete | global | destroy a problem, refused while it is attached anywhere |
problem:share | global | change who else can see it |
problem:archive | global | retire a problem without breaking what uses it |
problem:attach | both | attach a problem to a series and configure the assignment |
problem:import:external | global | create a problem whose slug carries a reserved prefix |
The reserved list is configuration, never a literal in the Server — it must not learn the name of any particular archive.
Submissions
| Permission | Scope | Means |
|---|---|---|
submission:read:own | activity | see one's own submissions |
submission:read:all | both | see everyone's submissions |
submission:create | activity | submit a solution. Membership is asked separately — see below |
submission:source:read:all | both | read the source of someone else's submission |
submission:rejudge | both | queue another evaluation attempt |
submission:cancel | both | cancel a queued or running job |
submission:exclude | both | rule that one submission counts toward no standing |
submission:source:read:all is separate from submission:read:all on purpose:
seeing that someone submitted, and reading what they wrote, are different
disclosures. submission:exclude is separate from submission:rejudge because
rejudging asks the Runner to look again while excluding decides an outcome by
hand.
Holding the key is not being in the activity
submission:create is the first of two questions; the second is membership.
Submitting is refused — 403, code enrollment.required — unless the submitter
holds an active grant in that activity. Resolution unions permissions, and
membership is not a permission: nothing carries it in from anywhere else.
Without the second question a submission would be graded and listed among the
manager's rows while belonging to nobody on the standings — the ranking builds
its contestants from activity grants.
system:administrator is not exempt, and that is the decision here worth
arguing with: the bypass is a bypass of permissions, and this is not a
permission question. An administrator who wants to compete takes an activity
grant, which costs them one request. An Invited grant is not membership
either — the resolver discards it, and so does this.
Two things it does not reach. Creating an activity writes its creator an active
grant in it, so nobody is shut out of what they just made. And trial:run is a
different door: a trial is nobody's submission, so nothing about it asks who is
in the activity.
Results
| Permission | Scope | Means |
|---|---|---|
result:read:own | activity | see one's own verdicts and per-test detail |
result:read:all | both | see everyone's |
result:log:read:all | both | read the evaluation log of any submission |
The activity's attachment rules govern participants; result:log:read:all
governs managers, and is what lets a jury read a log the participants cannot.
Questions and announcements
| Permission | Scope | Means |
|---|---|---|
question:read:own | activity | see one's own questions and every published one |
question:read:all | activity | see every question, published or not |
question:create | activity | ask a question |
question:answer | activity | answer a question |
question:publish | activity | make an answered question visible to everyone |
announcement:create | activity | publish an announcement |
Printouts
| Permission | Scope | Means |
|---|---|---|
printout:request | activity | ask for a page of source on paper |
printout:manage | both | work the queue: read a request and its source, print it, resolve it |
printout:manage is the one key an installation can hand out entirely on its
own. A grant carrying it and nothing else opens the printouts area of the
panel and refuses every other area, which is what lets somebody standing at a
printer be given that job and no other.
It is staff-conferring, and that has a consequence worth knowing before you hand it out: holding it in an activity takes the holder out of that activity's ranking. Reading source other people wrote is what staff means, and a volunteer who is also competing has to be given the key in an activity they do not compete in.
printout:request is in the participant role, so it confers nothing.
Requesting is refused anyway unless the activity has the module switched on.
Ranking
| Permission | Scope | Means |
|---|---|---|
ranking:read | activity | see the ranking as published |
ranking:read:unfrozen | both | see through a freeze |
ranking:unfreeze | both | lift the freeze for everyone |
Users
| Permission | Scope | Means |
|---|---|---|
user:read:all | both | name a person — search accounts, and see the account list at system scope |
user:create | global | create an account |
user:update | global | edit an account |
user:block | global | stop an account from signing in |
user:create:temporary | both | create temporary accounts in bulk |
user:merge | global | carry one account's work onto another |
user:create:temporary is the one account operation an activity manager needs —
a contest hands out accounts at the door — so it is the one that exists at
activity scope. user:merge is its own key rather than part of user:update:
it hands one person's submissions and points to somebody else, and it should not
arrive with ordinary account editing.
Grants and roles
| Permission | Scope | Means |
|---|---|---|
grant:read:all | both | see who holds what |
grant:update | both | change what someone holds |
role:read | both | see the roles — and so does grant:update, held anywhere |
role:manage | global | create, edit and delete the installation's roles |
role:manage:activity | both | create, edit and delete one activity's roles |
grant:update is why "nobody may grant a permission they do not hold" exists.
An activity manager needs it to run their own group, and without that rule it is
the same as being able to make oneself an administrator. The same rule governs
writing a role, because a grant links one: nobody may put into a role a
permission they do not themselves hold.
Two keys, because they are two different powers. A role the installation
shares is reachable from every activity, so writing one is an installation-wide
act: role:manage is global and an administrator's. A role belonging to one
activity reaches that activity and nothing else, so its manager may write it:
role:manage:activity, held in the grant on that activity.
It was one key held at either scope until 2026-09-19, and that was an escalation
rather than a convenience: a manager whose grant sat at system scope — which is
what a directory group hands out — could edit the shipped roles, including
emptying admin.
The role list answers to either key, held anywhere. GET /api/v1/roles
admits role:read or grant:update from any grant the caller holds —
applying a role is exactly what a manager does when they enroll somebody by hand,
and grant:update is the manager grant written before roles existed. Reading a
role discloses nothing, and the excess rule still refuses to hand on a permission
the caller does not hold.
Runners and the installation
| Permission | Scope | Means |
|---|---|---|
runner:read | global | see registered Runners and their state |
runner:approve | global | approve a self-registered key |
runner:revoke | global | revoke a key |
runner:update | global | rename, retag, add a note |
instance:update | global | what the installation is called, the mark it shows, the documents it publishes |
provider:manage | global | register identity providers, edit their claim mapping, enable and disable them |
system:administrator | global | bypasses every permission check and cannot be trimmed. Refused outright in an activity grant, and the last one cannot be taken away — see below |
provider:manage is the most dangerous key here after system:administrator,
because a mapping rule decides what a token buys. Its two guards are described
in Identity, and both are enforced rather than
documented.
instance:update is not in the manager role: running an activity is not
running the installation it lives in.
system:administrator is refused in an activity grant
It is a bypass only in a system-scoped grant. Writing it into an activity
grant is refused outright rather than stored and ignored — the code is
grant.permission.scope, and the message says the key is installation-wide and
means nothing in an activity.
The refusal is narrow on purpose. Several problem:* keys are declared global
too and are not refused in an activity grant, because the shipped manager
role contains them: a rule that refused every global key would refuse the
role this installation hands out.
Nor are they inert there. The problem library asks whether the key is held
anywhere rather than at system scope, so managing one activity is what
admits somebody to their own library — which is what the manager role
promises when it says it runs an activity's problems.
The installation cannot lose its last administrator
Revoking the only active system grant that carries the key is refused. So is
rewriting it so that it no longer carries the key, and so is demoting it to
invited — the resolver reads active grants only, so an invited administrator
administers nothing. All three answer 403 with the code
grant.administrator.last, and the message says to grant it to somebody else
first.
The role a grant links is defended the same way. Taking system:administrator
out of the role that is how the last administrator holds it, or deleting that
role, answers role.administrator.last and names the role. The shipped
administrator role is fixed outright and answers role.builtIn.fixed: an empty
permission set there would take the installation away from everybody linked to
it in one save, so it is granted and revoked rather than rewritten.
The question is asked about the row rather than the person: a second administrator anywhere in the installation makes every one of those acts legal again, including on the row that was refused a moment earlier.
The way back is a restart, because aj-admin has no command for grants.
Where no active system grant carries the key at all, the Server grants the
admin role back to the seeded admin account on start and logs a warning.
It never rewrites a system grant somebody made by hand: where that account
already holds one that does not administer, the log says so and the row is left
alone, so an operator who moved the role onto a named person keeps their
arrangement.
A restart returns the rights, not the password. If nobody knows that either,
aj-admin password sets one — see
Troubleshooting.
Archiving versus deletion
Both follow the same shape: archive is the ordinary act, deletion is the forceful one, and they are separate permissions so a manager can have the first without the second.
- An archived activity stays readable and accepts nothing new. It keeps its ranking and its history, because that is what participants come back for.
- An archived problem disappears from the attach picker and takes no new versions, while every assignment already using it keeps working.
- A problem that is attached anywhere cannot be deleted. The database refuses it on its own, whether or not the service layer remembers to check.