Start here
Permissions
A valid token proves who you are. It does not decide what you may do. That is decided by the roles on your account, which an operator sets. Every route except GET /v1/health needs a permission, and a request your roles do not cover answers 403 forbidden.
This page explains how to read your roles, how the API matches them against a request, which role grants each operation, and which roles to ask for.
Where to see your roles
GET /v1/api-account/validate-me returns them:
curl -s https://api.main-team.org/v1/api-account/validate-me \
-H "Authorization: Bearer $TOKEN" | jq .roles
[
{ "effect": "allow", "action": "api/*", "target": "mto" },
{ "effect": "allow", "action": "*/read", "target": "mto" },
{ "effect": "allow", "action": "student/*", "target": "mto" },
{ "effect": "allow", "action": "*/read", "target": "stem" },
{ "effect": "allow", "action": "student/update", "target": "stem" },
{ "effect": "allow", "action": "application/*", "target": "stem" },
{ "effect": "allow", "action": "auth/signin", "target": "stem" },
{ "effect": "disallow", "action": "application/delete", "target": "*" }
]
(validate-me itself needs api/* on mto. If it answers 403, ask for that role first. See Authentication.)
Only an operator can change roles. A change reaches the API within 60 seconds, and it applies to the tokens you already hold, because roles belong to your account, not to the token.
Anatomy of a role
A role has four fields:
| Field | Values | Default | Meaning |
|---|---|---|---|
effect | allow or disallow | allow | Whether the role grants the action or forbids it |
action | <resource>/<operation>, with * as a wildcard, or * alone | none | What the role is about, for example student/read or application/* |
target | *, mto, stem, hilingua, neo, gmath or coding | mto | Which organization the role covers. * means every organization and the core record |
authorized | Empty, or your own account _id | empty | Whose role it is. Leave it empty |
effect
allow grants, disallow forbids. A disallow that matches a request always wins over any allow that matches the same request (see Disallow wins).
action
An action is <resource>/<operation>. The API uses these resources and operations:
| Resource | Operations routes check | Covers |
|---|---|---|
api | * | validate-me, revoke-token |
country | read | Countries |
grade | read | Grades |
organization | read | Organizations |
student | read, create, update | Students, organization student views, supervisor links |
exam-category | read | Exam categories |
exam | read | Exams and the per-student exam picker |
application | read, create, update, delete | Applications |
certificate | read | Certificate lists and downloads |
report | read | Report lists and downloads |
group-challenge | read, submit | Group challenges, your students in them, their groups; submitting a step or a group's work for one of your students |
auth | signin | Sign-in links and student passwords (see auth/signin) |
target
The target says which organization a role covers. Every request acts on exactly one organization:
| The request is | It acts on | A role matches when its target is |
|---|---|---|
A flat route (no organization id in the path), such as /v1/student | mto, the core record | mto or * |
A per-organization route, /v1/{organizationId}/... | The organization whose _id is in the path | That organization's slug (for example stem) or * |
Consequences worth knowing:
- A role with target
stemdoes nothing on flat routes. Registering students (POST /v1/student) needs a role onmtoor*, even if all your work is on stem. - A role with target
mtodoes nothing on/v1/{stem's id}/.... - A role with target
*covers everything, flat routes included. Keep that in mind when you ask for*. - Because
mtois the default target, a role saved without a target covers only the flat routes.
Organizations lists which routes are flat.
authorized
authorized says whose role it is. Leave it empty: an empty authorized means "the account that holds this role", which is always what you want. Setting it to your own account _id means the same thing. A role whose authorized is any other id does nothing at all, neither as an allow nor as a disallow. If you see a role like that in validate-me, it is a mistake to report to the operator.
How a request is checked
For each request, the API knows the action the route needs (see the matrix) and the organization it acts on. It then:
- collects the matching roles: those whose
actiongrants the needed action, whosetargetis*or the request's organization, and whoseauthorizedis empty or your own id; - refuses if any matching role is a
disallow; - allows if any matching role is an
allow; - refuses otherwise. Having no matching role at all is a refusal.
The default is to refuse. A route is never open just because you have a token.
A refusal looks like this:
{
"error": {
"code": "forbidden",
"message": "Insufficient role permissions",
"documentation_url": "https://hub.main-team.org/api/errors#forbidden",
"request_id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9"
}
}
403 means your token is valid and your roles do not cover this request. Signing a new token or retrying does not help. Ask for the role.
Wildcards
In a role's action, * stands for "anything" in the segment where it appears, and * on its own grants every action.
| Role action | Grants | Does not grant |
|---|---|---|
* | Every action, including api/* and auth/signin | — |
*/* | Every action the API checks today, including api/*, auth/signin and group-challenge/submit | An action with more segments, should one ever be added |
*/read | country/read, grade/read, organization/read, student/read, exam-category/read, exam/read, application/read, certificate/read, report/read, group-challenge/read | api/*, and every write, group-challenge/submit included |
student/* | student/read, student/create, student/update | auth/signin, even though it concerns students |
application/* | application/read, create, update, delete | Everything else |
auth/* | auth/signin | Everything else |
group-challenge/* | group-challenge/read, group-challenge/submit | Everything else |
api/* | validate-me, revoke-token | Everything else |
exam/read | Exactly exam/read | exam-category/read (a different resource) |
Two rules decide every case:
- The segment counts must agree. A two-part pattern such as
student/*matches two-part actions only. Only*alone matches everything. - The
apiroutes needapi/*literally.validate-meandrevoke-tokenask forapi/*, and a role grants that only if its action isapi/*,*/*or*.*/readdoes not grant it, becausereadis not*, and neither doesapi/read.
Disallow wins
A disallow role that matches a request refuses it, whatever allow roles also match. This makes it easy to grant broadly and carve out exceptions:
| Roles | Result |
|---|---|
allow application/* on *, disallow application/delete on * | Read, create and move applications everywhere; never delete one |
allow * on *, disallow auth/signin on * | Everything except sign-in links and passwords |
allow auth/signin on *, disallow auth/signin on mto | Sign-in links on every olympiad; no passwords (the password routes are flat, so they act on mto) |
allow */read on *, disallow */read on coding | Read everything except on the coding olympiad |
A disallow follows the same matching rules as an allow. Its target must match the request's organization, and its authorized must be empty or your own id.
auth/signin is its own namespace
auth/signin grants the two ways of acting as a student in their panel:
| Operation | Route | The role needs target |
|---|---|---|
| Mint a sign-in link | POST /v1/{organizationId}/auth/signin | That organization's slug, or * |
| Set a student's password | PUT /v1/student/{studentId}/password | mto or * (a flat route) |
Send password when registering | POST /v1/student | mto or *, in addition to student/create |
Both are stronger than reading or updating a student: they let a browser sign in as that student. That is why no student/* or api/* role implies them, and why an operator has to grant auth/signin explicitly. *, */* and auth/* do include it, so a broad role grants it too, unless you add a disallow.
Registering a student with a password field but without auth/signin on mto is refused before anything is written:
{
"error": {
"code": "forbidden",
"message": "Setting a student's password needs the auth/signin permission on mto, the same grant a sign-in link needs.",
"documentation_url": "https://hub.main-team.org/api/errors#forbidden",
"request_id": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d"
}
}
Registering without a password needs only student/create. See Passwords and Sign-in links.
Other 403 answers
A few routes also answer 403 forbidden for a rule of their own, which has nothing to do with roles. Their message names the rule:
| Route | Message | What to do |
|---|---|---|
POST /v1/{organizationId}/auth/signin | Student is not activated for organization <slug>. | Give the student access to that organization with PUT /v1/{organizationId}/student/{studentId}. See Organizations |
POST /v1/student with password | Setting a student's password needs the auth/signin permission on mto, the same grant a sign-in link needs. | Register without a password, or ask for auth/signin on mto |
Only Insufficient role permissions means "ask for a role". Base your code on the status and error.code, and show the message to a person. The route-specific 403s are decided after the rate limit, so unlike a role refusal they count against it.
The order of checks
The permission check has a fixed place in the order of checks, which explains some answers that look surprising:
- Reading the body. A body over 100 kB (
413), in an encoding the API does not read (415), or that is not valid JSON (400) is refused first, before the token is looked at. - Token. A bad token is
401on every route. - Organization id (per-organization routes). An unknown id is
404 Organization not found!, even if you would have had no permission there. - Permission.
403if your roles do not cover the request. - Rate limit.
429. Requests refused at steps 2 to 4 are not counted. - Validating the body.
400for a missing, badly formatted or unknown field. So a request whose JSON is valid but whose fields are wrong, sent without the permission, answers403, not400. - The route's own rules. For example, a student who is not yours is treated as missing here, after the permission check (see Organizations). Without
student/readyou get403for every student id, yours or not.
Ready-made role profiles
These profiles cover the usual integrations. Copy the one that fits into your request to the operator. In every profile authorized is left empty.
Connectivity only
For monitoring or a first test.
| effect | action | target |
|---|---|---|
| allow | api/* | mto |
Reference data reader
Countries, grades and organizations, for building registration forms.
| effect | action | target |
|---|---|---|
| allow | api/* | mto |
| allow | country/read | mto |
| allow | grade/read | mto |
| allow | organization/read | mto |
(allow */read mto is shorter. It also grants reading your students on the core record.)
Enroll students on one olympiad
Register students, send them to the panel with sign-in links, enter them for exams, move and cancel entries, and collect certificates and reports, all on stem. Replace stem with your olympiad's slug.
| effect | action | target | Why |
|---|---|---|---|
| allow | api/* | mto | validate-me, revoke-token |
| allow | */read | mto | Countries, grades, organizations, your students on the core record |
| allow | student/* | mto | Register and update students |
| allow | */read | stem | Students, exam categories, exams, the exam picker, applications, certificates and reports on stem |
| allow | student/update | stem | Give students access to stem; link supervisors |
| allow | application/* | stem | Create, move and delete applications |
| allow | auth/signin | stem | Sign-in links into stem |
As JSON, for an operator:
[
{ "effect": "allow", "action": "api/*", "target": "mto" },
{ "effect": "allow", "action": "*/read", "target": "mto" },
{ "effect": "allow", "action": "student/*", "target": "mto" },
{ "effect": "allow", "action": "*/read", "target": "stem" },
{ "effect": "allow", "action": "student/update", "target": "stem" },
{ "effect": "allow", "action": "application/*", "target": "stem" },
{ "effect": "allow", "action": "auth/signin", "target": "stem" }
]
Adding passwords. If you also set students' passwords, add allow auth/signin on mto. Prefer sign-in links where you can; see Passwords.
Adding an olympiad. Repeat the four stem rows with the other slug.
Enroll students on every olympiad, without passwords
| effect | action | target |
|---|---|---|
| allow | api/* | * |
| allow | */read | * |
| allow | student/* | * |
| allow | application/* | * |
| allow | auth/signin | * |
| disallow | auth/signin | mto |
The last row keeps the password routes closed while sign-in links work everywhere.
Follow group challenges
Read where your students stand in an organization's group challenges, and submit their steps and their group's work. Replace stem with your olympiad's slug. Group challenges answer only on organizations where they are switched on.
| effect | action | target | Why |
|---|---|---|---|
| allow | api/* | mto | validate-me, revoke-token |
| allow | group-challenge/read | stem | The challenges, your students in them, their groups, steps, files and history |
| allow | group-challenge/submit | stem | Submit a step, or send the work, for one of your students |
| allow | auth/signin | stem | Sign-in links that open a challenge's page (panelPath) |
Leave out the group-challenge/submit row to follow without acting. */read already includes group-challenge/read; */* and * include both.
Results collector (read-only)
A nightly job that reads students, applications, certificates and reports, and changes nothing.
| effect | action | target |
|---|---|---|
| allow | api/* | mto |
| allow | */read | * |
Everything, with guard rails
| effect | action | target |
|---|---|---|
| allow | * | * |
| disallow | application/delete | * |
| disallow | auth/signin | mto |
* grants every action, auth/signin included. Drop the disallow rows only if you really need deletes or passwords.
Least privilege
Ask for the smallest profile that does the job. A leaked secret can do exactly what your roles allow, no more. A read-only job with read-only roles cannot change anything, whatever happens to its credentials. See Security.
Operation permission matrix
Every operation, the action it needs, and the role targets that grant it. Paths use {organizationId} for the organization _id.
On top of the exact action, every action below is also granted by *, by */*, by <resource>/* (for example student/*) and by */<operation> (for example */read), with one exception: api/* is granted only by api/*, */* and *.
Account and health
| Operation | Request | Action | Role target |
|---|---|---|---|
| getHealth | GET /v1/health | none (public, no token) | — |
| getCurrentApiAccount | GET /v1/api-account/validate-me | api/* | mto or * |
| revokeToken | POST /v1/api-account/revoke-token | api/* | mto or * |
Reference data
| Operation | Request | Action | Role target |
|---|---|---|---|
| listCountries | GET /v1/country | country/read | mto or * |
| getCountry | GET /v1/country/{id} | country/read | mto or * |
| listGrades | GET /v1/grade | grade/read | mto or * |
| getGrade | GET /v1/grade/{id} | grade/read | mto or * |
| listOrganizations | GET /v1/organization | organization/read | mto or * |
| getOrganization | GET /v1/organization/{id} | organization/read | mto or * |
Students on the core record
| Operation | Request | Action | Role target |
|---|---|---|---|
| listStudents | GET /v1/student | student/read | mto or * |
| getStudent | GET /v1/student/{studentId} | student/read | mto or * |
| registerStudent | POST /v1/student | student/create; with a password in the body, also auth/signin | mto or * (both) |
| checkStudentRegistration | POST /v1/student/check | student/create | mto or * |
| updateStudent | PUT /v1/student/{studentId} | student/update | mto or * |
| setStudentPassword | PUT /v1/student/{studentId}/password | auth/signin | mto or * |
Students in an organization
| Operation | Request | Action | Role target |
|---|---|---|---|
| listOrgStudents | GET /v1/{organizationId}/student | student/read | the organization's slug or * |
| getOrgStudent | GET /v1/{organizationId}/student/{studentId} | student/read | the organization's slug or * |
| updateOrgStudent | PUT /v1/{organizationId}/student/{studentId} | student/update | the organization's slug or * |
| linkStudentSupervisor | PUT /v1/{organizationId}/student/{studentId}/supervisor | student/update | the organization's slug or * |
| createSigninLink | POST /v1/{organizationId}/auth/signin | auth/signin | the organization's slug or * |
Exams
| Operation | Request | Action | Role target |
|---|---|---|---|
| listExamCategories | GET /v1/{organizationId}/exam-category | exam-category/read | the organization's slug or * |
| getExamCategory | GET /v1/{organizationId}/exam-category/{categoryId} | exam-category/read | the organization's slug or * |
| listExams | GET /v1/{organizationId}/exam | exam/read | the organization's slug or * |
| listAvailableExams | GET /v1/{organizationId}/exam/available/{studentId} | exam/read | the organization's slug or * |
| getExam | GET /v1/{organizationId}/exam/{examId} | exam/read | the organization's slug or * |
Applications
| Operation | Request | Action | Role target |
|---|---|---|---|
| listApplications | GET /v1/{organizationId}/application | application/read | the organization's slug or * |
| listExamApplications | GET /v1/{organizationId}/application/exam-applications/{examId} | application/read | the organization's slug or * |
| listStudentApplications | GET /v1/{organizationId}/application/student-applications/{studentId} | application/read | the organization's slug or * |
| getApplication | GET /v1/{organizationId}/application/{applicationId} | application/read | the organization's slug or * |
| createApplication | POST /v1/{organizationId}/application | application/create | the organization's slug or * |
| moveApplication | PUT /v1/{organizationId}/application/{applicationId} | application/update | the organization's slug or * |
| deleteApplication | DELETE /v1/{organizationId}/application/{applicationId} | application/delete | the organization's slug or * |
Certificates and reports
| Operation | Request | Action | Role target |
|---|---|---|---|
| listStudentCertificates | GET /v1/{organizationId}/certificate/{userId} | certificate/read | the organization's slug or * |
| downloadCertificate | GET /v1/{organizationId}/certificate/download/{certificateId} | certificate/read | the organization's slug or * |
| listStudentReports | GET /v1/{organizationId}/report/{userId} | report/read | the organization's slug or * |
| downloadReport | GET /v1/{organizationId}/report/download/{reportId} | report/read | the organization's slug or * |
Group challenges
| Operation | Request | Action | Role target |
|---|---|---|---|
| listGroupChallenges | GET /v1/{organizationId}/group-challenge | group-challenge/read | the organization's slug or * |
| getGroupChallenge | GET /v1/{organizationId}/group-challenge/{challengeId} | group-challenge/read | the organization's slug or * |
| listGroupChallengeStudents | GET /v1/{organizationId}/group-challenge/{challengeId}/student | group-challenge/read | the organization's slug or * |
| getGroupChallengeStudent | GET /v1/{organizationId}/group-challenge/{challengeId}/student/{studentId} | group-challenge/read | the organization's slug or * |
| listGroupChallengeGroups | GET /v1/{organizationId}/group-challenge/{challengeId}/group | group-challenge/read | the organization's slug or * |
| getGroupChallengeGroup | GET /v1/{organizationId}/group-challenge/{challengeId}/group/{groupId} | group-challenge/read | the organization's slug or * |
| listGroupChallengeActivity | GET /v1/{organizationId}/group-challenge/{challengeId}/group/{groupId}/activity | group-challenge/read | the organization's slug or * |
| submitGroupChallengeStep | POST /v1/{organizationId}/group-challenge/{challengeId}/group/{groupId}/step/{stepId}/submit | group-challenge/submit | the organization's slug or * |
| submitGroupChallengeWork | POST /v1/{organizationId}/group-challenge/{challengeId}/group/{groupId}/final-submit | group-challenge/submit | the organization's slug or * |
Check your roles in code
To catch a missing role before you make the call, for example to hide a button in your own admin screen, you can apply the same rules to the roles validate-me returns. This mirrors the API's rules. The API's own answer is still the one that counts.
// Does a stored role's action grant the needed action?
function actionGrants(pattern, action) {
if (!pattern) return false;
if (pattern === '*' || pattern === action) return true;
const p = pattern.split('/');
const a = action.split('/');
if (p.length !== a.length) return false;
return p.every((part, i) => part === '*' || part === a[i]);
}
/**
* account: the object from GET /v1/api-account/validate-me
* action: e.g. 'application/create' (see the matrix above)
* org: the organization slug the request acts on; 'mto' for flat routes
*/
function canCall(account, action, org) {
const matching = (account.roles ?? []).filter(
(role) =>
actionGrants(role.action, action) &&
(role.target === '*' || role.target === org) &&
(!role.authorized || role.authorized === account._id),
);
if (matching.some((role) => role.effect === 'disallow')) return false;
return matching.some((role) => role.effect === 'allow');
}
canCall(account, 'application/create', 'stem'); // true with the enrollment profile
canCall(account, 'auth/signin', 'mto'); // false: no password role
function actionGrants(?string $pattern, string $action): bool
{
if ($pattern === null || $pattern === '') return false;
if ($pattern === '*' || $pattern === $action) return true;
$p = explode('/', $pattern);
$a = explode('/', $action);
if (count($p) !== count($a)) return false;
foreach ($p as $i => $part) {
if ($part !== '*' && $part !== $a[$i]) return false;
}
return true;
}
function canCall(array $account, string $action, string $org): bool
{
$matching = array_filter($account['roles'] ?? [], fn ($role) =>
actionGrants($role['action'] ?? null, $action)
&& (($role['target'] ?? null) === '*' || ($role['target'] ?? null) === $org)
&& (empty($role['authorized']) || $role['authorized'] === $account['_id']));
foreach ($matching as $role) {
if (($role['effect'] ?? null) === 'disallow') return false;
}
foreach ($matching as $role) {
if (($role['effect'] ?? null) === 'allow') return true;
}
return false;
}
Roles can change at any time (within 60 seconds of an operator's edit), so refresh your copy of validate-me now and then rather than keeping it forever.
Asking for a role
Write to info@main-team.org from your registered contact address with:
- your
apiKey(never the secret), - the operations you need, by name from the matrix, or a profile from this page,
- the organizations, by slug,
- for a
403you already hit: therequest_id, the time in UTC and the request you made.
A granted role works within 60 seconds, with the token you already have.