# Permissions

> How roles grant access. The effect, action, target and authorized fields, wildcards, why disallow wins, auth/signin, ready-made profiles and a full matrix.

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:

```bash
curl -s https://api.main-team.org/v1/api-account/validate-me \
  -H "Authorization: Bearer $TOKEN" | jq .roles
```

```json
[
  { "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](https://hub.main-team.org/api/authentication#check-a-token-validate-me).)

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](#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](#authsignin-is-its-own-namespace)) |

### 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 `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](https://hub.main-team.org/api/organizations#two-kinds-of-routes) 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](#operation-permission-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:

```json
{
  "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:

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:

| 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:

```json
{
  "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](https://hub.main-team.org/api/guides/passwords) and [Sign-in links](https://hub.main-team.org/api/guides/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](https://hub.main-team.org/api/organizations#which-students-an-organization-route-shows) |
| `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 `403`s 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](https://hub.main-team.org/api/organizations#a-foreign-record-looks-like-a-missing-one)). 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.

| 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:

```json
[
  { "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](https://hub.main-team.org/api/guides/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](https://hub.main-team.org/api/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](https://hub.main-team.org/api/reference/get-health) | `GET /v1/health` | none (public, no token) | — |
| [getCurrentApiAccount](https://hub.main-team.org/api/reference/get-current-api-account) | `GET /v1/api-account/validate-me` | `api/*` | `mto` or `*` |
| [revokeToken](https://hub.main-team.org/api/reference/revoke-token) | `POST /v1/api-account/revoke-token` | `api/*` | `mto` or `*` |

### Reference data

| Operation | Request | Action | Role target |
|---|---|---|---|
| [listCountries](https://hub.main-team.org/api/reference/list-countries) | `GET /v1/country` | `country/read` | `mto` or `*` |
| [getCountry](https://hub.main-team.org/api/reference/get-country) | `GET /v1/country/{id}` | `country/read` | `mto` or `*` |
| [listGrades](https://hub.main-team.org/api/reference/list-grades) | `GET /v1/grade` | `grade/read` | `mto` or `*` |
| [getGrade](https://hub.main-team.org/api/reference/get-grade) | `GET /v1/grade/{id}` | `grade/read` | `mto` or `*` |
| [listOrganizations](https://hub.main-team.org/api/reference/list-organizations) | `GET /v1/organization` | `organization/read` | `mto` or `*` |
| [getOrganization](https://hub.main-team.org/api/reference/get-organization) | `GET /v1/organization/{id}` | `organization/read` | `mto` or `*` |

### Students on the core record

| Operation | Request | Action | Role target |
|---|---|---|---|
| [listStudents](https://hub.main-team.org/api/reference/list-students) | `GET /v1/student` | `student/read` | `mto` or `*` |
| [getStudent](https://hub.main-team.org/api/reference/get-student) | `GET /v1/student/{studentId}` | `student/read` | `mto` or `*` |
| [registerStudent](https://hub.main-team.org/api/reference/register-student) | `POST /v1/student` | `student/create`; with a `password` in the body, also `auth/signin` | `mto` or `*` (both) |
| [checkStudentRegistration](https://hub.main-team.org/api/reference/check-student-registration) | `POST /v1/student/check` | `student/create` | `mto` or `*` |
| [updateStudent](https://hub.main-team.org/api/reference/update-student) | `PUT /v1/student/{studentId}` | `student/update` | `mto` or `*` |
| [setStudentPassword](https://hub.main-team.org/api/reference/set-student-password) | `PUT /v1/student/{studentId}/password` | `auth/signin` | `mto` or `*` |

### Students in an organization

| Operation | Request | Action | Role target |
|---|---|---|---|
| [listOrgStudents](https://hub.main-team.org/api/reference/list-org-students) | `GET /v1/{organizationId}/student` | `student/read` | the organization's slug or `*` |
| [getOrgStudent](https://hub.main-team.org/api/reference/get-org-student) | `GET /v1/{organizationId}/student/{studentId}` | `student/read` | the organization's slug or `*` |
| [updateOrgStudent](https://hub.main-team.org/api/reference/update-org-student) | `PUT /v1/{organizationId}/student/{studentId}` | `student/update` | the organization's slug or `*` |
| [linkStudentSupervisor](https://hub.main-team.org/api/reference/link-student-supervisor) | `PUT /v1/{organizationId}/student/{studentId}/supervisor` | `student/update` | the organization's slug or `*` |
| [createSigninLink](https://hub.main-team.org/api/reference/create-signin-link) | `POST /v1/{organizationId}/auth/signin` | `auth/signin` | the organization's slug or `*` |

### Exams

| Operation | Request | Action | Role target |
|---|---|---|---|
| [listExamCategories](https://hub.main-team.org/api/reference/list-exam-categories) | `GET /v1/{organizationId}/exam-category` | `exam-category/read` | the organization's slug or `*` |
| [getExamCategory](https://hub.main-team.org/api/reference/get-exam-category) | `GET /v1/{organizationId}/exam-category/{categoryId}` | `exam-category/read` | the organization's slug or `*` |
| [listExams](https://hub.main-team.org/api/reference/list-exams) | `GET /v1/{organizationId}/exam` | `exam/read` | the organization's slug or `*` |
| [listAvailableExams](https://hub.main-team.org/api/reference/list-available-exams) | `GET /v1/{organizationId}/exam/available/{studentId}` | `exam/read` | the organization's slug or `*` |
| [getExam](https://hub.main-team.org/api/reference/get-exam) | `GET /v1/{organizationId}/exam/{examId}` | `exam/read` | the organization's slug or `*` |

### Applications

| Operation | Request | Action | Role target |
|---|---|---|---|
| [listApplications](https://hub.main-team.org/api/reference/list-applications) | `GET /v1/{organizationId}/application` | `application/read` | the organization's slug or `*` |
| [listExamApplications](https://hub.main-team.org/api/reference/list-exam-applications) | `GET /v1/{organizationId}/application/exam-applications/{examId}` | `application/read` | the organization's slug or `*` |
| [listStudentApplications](https://hub.main-team.org/api/reference/list-student-applications) | `GET /v1/{organizationId}/application/student-applications/{studentId}` | `application/read` | the organization's slug or `*` |
| [getApplication](https://hub.main-team.org/api/reference/get-application) | `GET /v1/{organizationId}/application/{applicationId}` | `application/read` | the organization's slug or `*` |
| [createApplication](https://hub.main-team.org/api/reference/create-application) | `POST /v1/{organizationId}/application` | `application/create` | the organization's slug or `*` |
| [moveApplication](https://hub.main-team.org/api/reference/move-application) | `PUT /v1/{organizationId}/application/{applicationId}` | `application/update` | the organization's slug or `*` |
| [deleteApplication](https://hub.main-team.org/api/reference/delete-application) | `DELETE /v1/{organizationId}/application/{applicationId}` | `application/delete` | the organization's slug or `*` |

### Certificates and reports

| Operation | Request | Action | Role target |
|---|---|---|---|
| [listStudentCertificates](https://hub.main-team.org/api/reference/list-student-certificates) | `GET /v1/{organizationId}/certificate/{userId}` | `certificate/read` | the organization's slug or `*` |
| [downloadCertificate](https://hub.main-team.org/api/reference/download-certificate) | `GET /v1/{organizationId}/certificate/download/{certificateId}` | `certificate/read` | the organization's slug or `*` |
| [listStudentReports](https://hub.main-team.org/api/reference/list-student-reports) | `GET /v1/{organizationId}/report/{userId}` | `report/read` | the organization's slug or `*` |
| [downloadReport](https://hub.main-team.org/api/reference/download-report) | `GET /v1/{organizationId}/report/download/{reportId}` | `report/read` | the organization's slug or `*` |

### Group challenges

| Operation | Request | Action | Role target |
|---|---|---|---|
| [listGroupChallenges](https://hub.main-team.org/api/reference/list-group-challenges) | `GET /v1/{organizationId}/group-challenge` | `group-challenge/read` | the organization's slug or `*` |
| [getGroupChallenge](https://hub.main-team.org/api/reference/get-group-challenge) | `GET /v1/{organizationId}/group-challenge/{challengeId}` | `group-challenge/read` | the organization's slug or `*` |
| [listGroupChallengeStudents](https://hub.main-team.org/api/reference/list-group-challenge-students) | `GET /v1/{organizationId}/group-challenge/{challengeId}/student` | `group-challenge/read` | the organization's slug or `*` |
| [getGroupChallengeStudent](https://hub.main-team.org/api/reference/get-group-challenge-student) | `GET /v1/{organizationId}/group-challenge/{challengeId}/student/{studentId}` | `group-challenge/read` | the organization's slug or `*` |
| [listGroupChallengeGroups](https://hub.main-team.org/api/reference/list-group-challenge-groups) | `GET /v1/{organizationId}/group-challenge/{challengeId}/group` | `group-challenge/read` | the organization's slug or `*` |
| [getGroupChallengeGroup](https://hub.main-team.org/api/reference/get-group-challenge-group) | `GET /v1/{organizationId}/group-challenge/{challengeId}/group/{groupId}` | `group-challenge/read` | the organization's slug or `*` |
| [listGroupChallengeActivity](https://hub.main-team.org/api/reference/list-group-challenge-activity) | `GET /v1/{organizationId}/group-challenge/{challengeId}/group/{groupId}/activity` | `group-challenge/read` | the organization's slug or `*` |
| [submitGroupChallengeStep](https://hub.main-team.org/api/reference/submit-group-challenge-step) | `POST /v1/{organizationId}/group-challenge/{challengeId}/group/{groupId}/step/{stepId}/submit` | `group-challenge/submit` | the organization's slug or `*` |
| [submitGroupChallengeWork](https://hub.main-team.org/api/reference/submit-group-challenge-work) | `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.

```js
// 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
```

```php
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](#operation-permission-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.
