Skip to content
API documentation
View as MarkdownOpen in Claude

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:

FieldValuesDefaultMeaning
effectallow or disallowallowWhether the role grants the action or forbids it
action<resource>/<operation>, with * as a wildcard, or * alonenoneWhat the role is about, for example student/read or application/*
target*, mto, stem, hilingua, neo, gmath or codingmtoWhich organization the role covers. * means every organization and the core record
authorizedEmpty, or your own account _idemptyWhose 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:

ResourceOperations routes checkCovers
api*validate-me, revoke-token
countryreadCountries
gradereadGrades
organizationreadOrganizations
studentread, create, updateStudents, organization student views, supervisor links
exam-categoryreadExam categories
examreadExams and the per-student exam picker
applicationread, create, update, deleteApplications
certificatereadCertificate lists and downloads
reportreadReport lists and downloads
group-challengeread, submitGroup challenges, your students in them, their groups; submitting a step or a group's work for one of your students
authsigninSign-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 isIt acts onA role matches when its target is
A flat route (no organization id in the path), such as /v1/studentmto, the core recordmto or *
A per-organization route, /v1/{organizationId}/...The organization whose _id is in the pathThat organization's slug (for example stem) or *

Consequences worth knowing:

  • A role with target stem does nothing on flat routes. Registering students (POST /v1/student) needs a role on mto or *, even if all your work is on stem.
  • A role with target mto does nothing on /v1/{stem's id}/....
  • A role with target * covers everything, flat routes included. Keep that in mind when you ask for *.
  • Because mto is 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:

  1. collects the matching roles: those whose action grants the needed action, whose target is * or the request's organization, and whose authorized is empty or your own id;
  2. refuses if any matching role is a disallow;
  3. allows if any matching role is an allow;
  4. 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 actionGrantsDoes not grant
*Every action, including api/* and auth/signin—
*/*Every action the API checks today, including api/*, auth/signin and group-challenge/submitAn action with more segments, should one ever be added
*/readcountry/read, grade/read, organization/read, student/read, exam-category/read, exam/read, application/read, certificate/read, report/read, group-challenge/readapi/*, and every write, group-challenge/submit included
student/*student/read, student/create, student/updateauth/signin, even though it concerns students
application/*application/read, create, update, deleteEverything else
auth/*auth/signinEverything else
group-challenge/*group-challenge/read, group-challenge/submitEverything else
api/*validate-me, revoke-tokenEverything else
exam/readExactly exam/readexam-category/read (a different resource)

Two rules decide every case:

  1. The segment counts must agree. A two-part pattern such as student/* matches two-part actions only. Only * alone matches everything.
  2. The api routes need api/* literally. validate-me and revoke-token ask for api/*, and a role grants that only if its action is api/*, */* or *. */read does not grant it, because read is not *, and neither does api/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:

RolesResult
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 mtoSign-in links on every olympiad; no passwords (the password routes are flat, so they act on mto)
allow */read on *, disallow */read on codingRead 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:

OperationRouteThe role needs target
Mint a sign-in linkPOST /v1/{organizationId}/auth/signinThat organization's slug, or *
Set a student's passwordPUT /v1/student/{studentId}/passwordmto or * (a flat route)
Send password when registeringPOST /v1/studentmto 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:

RouteMessageWhat to do
POST /v1/{organizationId}/auth/signinStudent 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 passwordSetting 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:

  1. 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.
  2. Token. A bad token is 401 on every route.
  3. Organization id (per-organization routes). An unknown id is 404 Organization not found!, even if you would have had no permission there.
  4. Permission. 403 if your roles do not cover the request.
  5. Rate limit. 429. Requests refused at steps 2 to 4 are not counted.
  6. Validating the body. 400 for a missing, badly formatted or unknown field. So a request whose JSON is valid but whose fields are wrong, sent without the permission, answers 403, not 400.
  7. 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/read you get 403 for 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.

effectactiontarget
allowapi/*mto

Reference data reader

Countries, grades and organizations, for building registration forms.

effectactiontarget
allowapi/*mto
allowcountry/readmto
allowgrade/readmto
alloworganization/readmto

(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.

effectactiontargetWhy
allowapi/*mtovalidate-me, revoke-token
allow*/readmtoCountries, grades, organizations, your students on the core record
allowstudent/*mtoRegister and update students
allow*/readstemStudents, exam categories, exams, the exam picker, applications, certificates and reports on stem
allowstudent/updatestemGive students access to stem; link supervisors
allowapplication/*stemCreate, move and delete applications
allowauth/signinstemSign-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

effectactiontarget
allowapi/**
allow*/read*
allowstudent/**
allowapplication/**
allowauth/signin*
disallowauth/signinmto

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.

effectactiontargetWhy
allowapi/*mtovalidate-me, revoke-token
allowgroup-challenge/readstemThe challenges, your students in them, their groups, steps, files and history
allowgroup-challenge/submitstemSubmit a step, or send the work, for one of your students
allowauth/signinstemSign-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.

effectactiontarget
allowapi/*mto
allow*/read*

Everything, with guard rails

effectactiontarget
allow**
disallowapplication/delete*
disallowauth/signinmto

* 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

OperationRequestActionRole target
getHealthGET /v1/healthnone (public, no token)—
getCurrentApiAccountGET /v1/api-account/validate-meapi/*mto or *
revokeTokenPOST /v1/api-account/revoke-tokenapi/*mto or *

Reference data

OperationRequestActionRole target
listCountriesGET /v1/countrycountry/readmto or *
getCountryGET /v1/country/{id}country/readmto or *
listGradesGET /v1/gradegrade/readmto or *
getGradeGET /v1/grade/{id}grade/readmto or *
listOrganizationsGET /v1/organizationorganization/readmto or *
getOrganizationGET /v1/organization/{id}organization/readmto or *

Students on the core record

OperationRequestActionRole target
listStudentsGET /v1/studentstudent/readmto or *
getStudentGET /v1/student/{studentId}student/readmto or *
registerStudentPOST /v1/studentstudent/create; with a password in the body, also auth/signinmto or * (both)
checkStudentRegistrationPOST /v1/student/checkstudent/createmto or *
updateStudentPUT /v1/student/{studentId}student/updatemto or *
setStudentPasswordPUT /v1/student/{studentId}/passwordauth/signinmto or *

Students in an organization

OperationRequestActionRole target
listOrgStudentsGET /v1/{organizationId}/studentstudent/readthe organization's slug or *
getOrgStudentGET /v1/{organizationId}/student/{studentId}student/readthe organization's slug or *
updateOrgStudentPUT /v1/{organizationId}/student/{studentId}student/updatethe organization's slug or *
linkStudentSupervisorPUT /v1/{organizationId}/student/{studentId}/supervisorstudent/updatethe organization's slug or *
createSigninLinkPOST /v1/{organizationId}/auth/signinauth/signinthe organization's slug or *

Exams

OperationRequestActionRole target
listExamCategoriesGET /v1/{organizationId}/exam-categoryexam-category/readthe organization's slug or *
getExamCategoryGET /v1/{organizationId}/exam-category/{categoryId}exam-category/readthe organization's slug or *
listExamsGET /v1/{organizationId}/examexam/readthe organization's slug or *
listAvailableExamsGET /v1/{organizationId}/exam/available/{studentId}exam/readthe organization's slug or *
getExamGET /v1/{organizationId}/exam/{examId}exam/readthe organization's slug or *

Applications

OperationRequestActionRole target
listApplicationsGET /v1/{organizationId}/applicationapplication/readthe organization's slug or *
listExamApplicationsGET /v1/{organizationId}/application/exam-applications/{examId}application/readthe organization's slug or *
listStudentApplicationsGET /v1/{organizationId}/application/student-applications/{studentId}application/readthe organization's slug or *
getApplicationGET /v1/{organizationId}/application/{applicationId}application/readthe organization's slug or *
createApplicationPOST /v1/{organizationId}/applicationapplication/createthe organization's slug or *
moveApplicationPUT /v1/{organizationId}/application/{applicationId}application/updatethe organization's slug or *
deleteApplicationDELETE /v1/{organizationId}/application/{applicationId}application/deletethe organization's slug or *

Certificates and reports

OperationRequestActionRole target
listStudentCertificatesGET /v1/{organizationId}/certificate/{userId}certificate/readthe organization's slug or *
downloadCertificateGET /v1/{organizationId}/certificate/download/{certificateId}certificate/readthe organization's slug or *
listStudentReportsGET /v1/{organizationId}/report/{userId}report/readthe organization's slug or *
downloadReportGET /v1/{organizationId}/report/download/{reportId}report/readthe organization's slug or *

Group challenges

OperationRequestActionRole target
listGroupChallengesGET /v1/{organizationId}/group-challengegroup-challenge/readthe organization's slug or *
getGroupChallengeGET /v1/{organizationId}/group-challenge/{challengeId}group-challenge/readthe organization's slug or *
listGroupChallengeStudentsGET /v1/{organizationId}/group-challenge/{challengeId}/studentgroup-challenge/readthe organization's slug or *
getGroupChallengeStudentGET /v1/{organizationId}/group-challenge/{challengeId}/student/{studentId}group-challenge/readthe organization's slug or *
listGroupChallengeGroupsGET /v1/{organizationId}/group-challenge/{challengeId}/groupgroup-challenge/readthe organization's slug or *
getGroupChallengeGroupGET /v1/{organizationId}/group-challenge/{challengeId}/group/{groupId}group-challenge/readthe organization's slug or *
listGroupChallengeActivityGET /v1/{organizationId}/group-challenge/{challengeId}/group/{groupId}/activitygroup-challenge/readthe organization's slug or *
submitGroupChallengeStepPOST /v1/{organizationId}/group-challenge/{challengeId}/group/{groupId}/step/{stepId}/submitgroup-challenge/submitthe organization's slug or *
submitGroupChallengeWorkPOST /v1/{organizationId}/group-challenge/{challengeId}/group/{groupId}/final-submitgroup-challenge/submitthe 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 403 you already hit: the request_id, the time in UTC and the request you made.

A granted role works within 60 seconds, with the token you already have.

Search the API documentation

Guides, endpoints by name, path or permission, and error codes such as not_found.