Getting started
Platform accounts: who opens your app
Scopes answer "what data does your app read". This section answers something else entirely: who is sitting in front of the screen. The platform knows four accounts, and they do not work by one mechanism — that difference breaks more assumptions than anything else in the integration.
| Account | Recorded in | Scope | Entity in the context | How you address it |
|---|---|---|---|---|
| Teacher / administrator | tenant_employees + a role in the entity |
tenant |
tenant_id |
"roles": ["teacher"] |
| Student | tenant_students |
general |
portal_tenant_id only |
"portal": "student" |
| Guardian | tenant_guardians |
general |
portal_tenant_id only |
"portal": "guardian" |
| Guardian delegate | a delegation row — no membership row of their own | general |
portal_tenant_id only |
"portal": "guardian-delegate" |
1. The employee — a role inside the entity
Teachers and administrators live inside the entity: a row in
tenant_employees carrying a type (their job), a membership in the entity,
and a role. All three are needed — the membership is what makes the entity
appear in the context picker, and the role is what actually lets them in.
A member of staff is one type: the school records them as a teacher or as
an administrator — not both, and not "employee" beside either, since either one
is an employee already — and they are granted the teacher or administrator
role. It carries no permissions at all: a key to the door and nothing more.
What they may DO is decided afterwards, by attaching permissions to that role
or by putting them in an administrator group.
Inside your app this is the "ordinary" account: Tenant::current() is set, the
scope is tenant, and everything you know about entity context holds.
Matching is wider than you would guess. "roles": ["teacher"] is matched
against three things at once: the active role, every role selected in the
session, and tenant_employees.type itself. All of them are normalised
before comparison (lowercased, spaces and _ folded to -), so "Teacher"
and "teacher" are the same thing.
2 and 3. Student and guardian — portals, not roles
Here everything changes. A student and a guardian have no role row at all:
the portal's name IS the audience. The scope is general, tenant_id in the
context is empty, and the entity lives under a different key entirely:
portal_tenant_id.
This is not a naming detail. A portal account has no entity context in the
usual sense. The platform boots its entity for that one request, and only
on the app pages your manifest declared; outside them it stays entity-less, and
the platform's own entity-scoped pages keep refusing it. Any code assuming
Tenant::current() is always there will fall over on a portal page.
And a guardian crosses entities: they may have children in two schools, so they pick the entity on the way in — and that entity is the one the sidebar carries and the one apps open against.
4. The guardian's delegate — a portal of its own, not a flavour of guardian
A guardian can authorise somebody to act for their children: a relative who collects a child from the gate, a driver. The delegation is not a second guardian account; it is a slice of that one parent's own authority:
- over named children — the parent lists them one by one, never "all of mine";
- with a subset of their capabilities — they choose what to hand over (the pickup call, attendance, …);
- and usually until a date — a delegation for exam week ends with it.
A delegate holds no membership row at all: no tenant_guardians, no
tenant_students. Their entire existence in that school is a delegation row,
so they disappear on their own when it expires, is revoked, or the delegating
parent loses their own link to the child.
Why a portal of its own rather than a flavour of guardian: an app that
serves parents does not thereby serve whoever a parent handed a key to — it has
to SAY it serves them. Had a delegate arrived under the guardian key, they
would have matched every guardian audience published today without anybody
declaring anything. The cost is stated plainly: your app does not reach
delegates until it declares this audience and publishes.
"account_types": [
{ "key": "guardian", "kind": "dependent", "portal": "guardian",
"web_route": "call.family", "mobile_entry": "okta_app/webview/family.html" },
{ "key": "guardian-delegate", "kind": "dependent", "portal": "guardian-delegate",
"web_route": "call.delegate", "mobile_entry": "okta_app/webview/delegate.html" }
]
The platform enforces the bounds; you draw them. Refusing is not on you:
student reads in a delegate's context are narrowed SERVER-SIDE to the children
of that delegation — ListStudents returns those and no others, GetStudent
for a pupil outside them answers the same 404 an unknown id gets (so the
refusal never tells the delegate who else is enrolled), and the delegating
parent's own children list comes back with the delegated children rather
than their whole family. A delegation that has lapsed reaches nothing: it is
pinned to nobody, not unpinned.
What you draw is the button. $viewer in a delegate's context carries:
| What you read | What it means |
|---|---|
$viewer->type / accountType |
guardian_delegate |
$viewer->isDelegate() |
true — the branch you draw the delegate screen on |
$viewer->delegatedChildren() |
[{id, capabilities, expiresAt}] — id is the same student id the student services return, so you join on it directly |
$viewer->delegatedChildIds() |
just the ids, for filtering your own rows |
$viewer->mayForChild($childId, 'okta_hdor.pickup.call') |
may they do this, for this child, today |
@if ($viewer->isDelegate())
@foreach ($viewer->delegatedChildren() as $child)
{{-- draw the card, and the call button only for whoever holds it --}}
@if ($viewer->mayForChild($child['id'], 'okta_hdor.pickup.call'))
<x-button wire:click="call('{{ $child['id'] }}')">{{ __('Call') }}</x-button>
@endif
@endforeach
@endif
delegatedChildren()is never empty in a delegate's context: a delegate with nothing effective left is answered$viewer === null, exactly as a released guardian is. It is a list to DRAW, not a list to test for emptiness.expiresAtis when the delegation ends (nullfor one that does not) — show it: "ends Thursday" explains the button's disappearance the day after.mayForChildis for drawing, not for stopping: the platform refuses the action outside the delegation itself, so a screen that forgets the check still cannot act. For anybody who is not a delegate it answersfalse, so gate a parent's own screen on being a guardian rather than on this.
Every capability is yours — the platform declares none
Every capability a parent can hand to a delegate is declared by an installed app. There is no platform list, and you will not find one to choose from.
There was one: nine fixed capabilities (pickup_student, view_marks,
pay_fees and the rest) shown to every parent at every school. Nothing read
them — no screen, no endpoint and no middleware on the platform ever asked
whether a delegate held one. The only reader was, and is, mayForChild() in an
installed app. And the first app that needed a capability declared its own
rather than use pickup_student — two checkboxes for one act, and the parent
could not tell which the school would honour. So the vocabulary went to whoever
enforces it.
What that means for you: an organisation where no installed app declares a capability shows the parent no checkboxes at all, and the delegation still means «this person reaches these children». If you want a parent to be able to hand on something of yours, declare it — otherwise nothing is handed on.
Declare them in manifest.json, the family-side twin of rbac_permissions:
"guardian_capabilities": [
{ "key": "okta_hdor.pickup.call",
"label": { "ar": "إجراء النداء", "en": "Place the pickup call" } },
{ "key": "okta_hdor.attendance.read",
"label": { "ar": "عرض حضور الابن", "en": "View attendance" },
"gates": ["okta_hdor.attendance.read"] }
]
- The key is
<feature>.<resource>.<action>withfeature= your slug in snake_case (okta-hdor→okta_hdor) — the same rulerbac_permissionscarries, enforced just as hard. Not for tidiness: the key is stored on a FAMILY'S DELEGATION and read back by whichever app asks, so declaring in another app's namespace would put a checkbox into the guardian's screen on that partner's behalf, under a heading the parent reads as the platform's. A key outside your namespace is refused by name. label.aris required — it is the whole of what a parent reads beside the checkbox, and an unlabelled box is one nobody can tick honestly.label.enfalls back to the Arabic.- The school neither grants nor withholds them, and is not asked. A guardian holds every one by being a guardian in that organisation, and the only decision is theirs: which to hand on. The bound that remains is the guardian's own link to the child — a child their parent no longer reaches drops out of the delegation entirely.
- The school reads what happened without taking part: a «Delegates» page in the organisation's settings lists who authorised whom, over which children, with which capabilities and until when. Read-only.
- They appear to the parent grouped under your app's name, in the delegation screen at every organisation that installed you. The bounds still hold: the delegation itself, its end date, and the children it names.
Read them at runtime with the same function:
if ($viewer->mayForChild($childId, 'okta_hdor.pickup.call')) {
// draw the call button for this child
}
- And nobody is authorized over themselves: a delegation naming a child whose account IS the delegate is refused on create and on edit, and the children picker does not offer them at all.
gates is optional, and leaving it empty is the ordinary case: you read
the capability yourself and draw on it, and nothing changes for anything
already published. Naming YOUR OWN scopes there moves the refusal to the
PLATFORM: a call using that scope in a delegate's context, by somebody who was
not granted the capability, is refused before it reaches your code. Opt-in per
capability, so nothing already shipped changes behaviour.
The block reaches a version from your repo through «Sync from manifest» or the
sync_from_manifest tool, and is read and written with
get_guardian_capabilities / set_guardian_capabilities. Absent, it is left
alone: a repo that stops naming the key does not silently withdraw
capabilities families have already been offered.
Declaring your audience
The "Account types" tab in the version editor — or set_account_types over
MCP — is what becomes menu.audiences[] in okta-web and mobile.audiences[]
in the Okta app. For each type:
"account_types": [
{ "key": "teacher", "kind": "primary", "roles": ["teacher"], "web_route": "school-app.staff" },
{ "key": "guardian", "kind": "dependent", "portal": "guardian", "web_route": "school-app.family" }
]
kind—primary(the main audience) ordependent.- Exactly one target:
roles(roles inside the entity) orportal. - At least one surface:
web_routeand/ormobile_entry.
web_route is a Laravel route NAME — not a URL, not a /path — and it is a
namespace bound: school-app.admin covers school-app.admin.reports and
does NOT cover school-app.admins. The segment shape carries meaning; don't
write it casually.
The third target: permissions[] — what the school granted, not your guess at the role
The role names in roles[] are your guess at what schools call things, and
a school creates its own custom roles and attaches staff to them: "Accounting",
"Staff affairs", "Attendance supervisor". So an employee whose account type is
administrator, given the management permissions through a custom role, was
still routed to the administrators' screen — because the launcher picked the
route by role alone.
Hence a third target for an audience: the permission.
"account_types": [
{ "key": "management", "kind": "primary", "permissions": ["okta_hr.management.view"], "web_route": "okta-hr.admin" },
{ "key": "administrator", "kind": "dependent", "roles": ["administrator"], "web_route": "okta-hr.staff" }
]
- It matches whoever holds ANY of the named permissions in the entity — read from the tables at request time, whether it came from their base role or from a custom role the school created and attached them to. The name of that role, and how it differs between schools, is none of your business.
- The names are your own permissions in full
<feature>.<resource>.<action>form, and declare them inrbac_permissionstoo — otherwise the school cannot grant them to anybody. - Order is the decision: the launcher links the tile to the first matching audience (primary before dependent, then declaration order). Put the permission audience before the role audiences so it wins for people who match both.
- A standalone target: never together with
roles[]orportal(a portal account holds no permission in the entity at all), and it needsweb_route. - okta-web only: the Okta app matches its cards by role and portal, and a
permission holder gets the general staff card there — so no
mobile_entryon this type.
This does not replace can() inside the screen: the audience decides where
someone enters, the permission decides what they see and do once inside.
Entity type: entity_types — a complex's admin gets a different screen from a school's
A tenant admin at a complex runs schools; a tenant admin at a school runs
pupils — one account type (tenant-admin), not one screen. The only way used
to be a single package that asks what kind of entity it is standing in and
branches, so the complex's screen was downloaded and compiled on every school's
phone. Now you declare two rows for the same type, and the platform picks by
the kind of entity the person is standing in:
"account_types": [
{ "key": "tenant-admin", "roles": ["tenant-admin"],
"web_route": "okta-hr.dashboard",
"mobile_entry": "okta_app/native/main/lib/main.dart" },
{ "key": "tenant-admin", "roles": ["tenant-admin"],
"entity_types": ["complex", "education_company"],
"web_route": "okta-hr.group",
"mobile_entry": "okta_app/native/group/lib/main.dart" }
]
In manifest.json it is entityTypes on the audience, in menu.audiences[]
and mobile.audiences[] alike:
{ "key": "tenant-admin@complex+education_company", "kind": "primary",
"roles": ["tenant-admin"], "entityTypes": ["complex", "education_company"],
"entry": "okta_app/native/group/lib/main.dart" }
- The narrowest wins, not the first. Where the narrowed row applies it wins over the same type's unnarrowed row whatever the declaration order, and the unnarrowed row serves every other kind of entity. So the usual shape is two rows: a general one for every entity, and a narrowed one where a screen of its own is needed.
- No
entity_types= every kind of entity, which is every app published before the field: its manifest does not change by a byte, and neither does what anyone sees. - A closed vocabulary:
individual_teacher,school,complex,college,university,institute,academy,education_company,daycare_center. An unknown name is refused by name, not dropped: dropping it narrows the row silently, and dropping its last name widens it to every kind of entity — the exact opposite of what was written. - Staff only: on
rolesandpermissions. Refused on a portal: students, guardians and delegates reach across entities (children in two schools), and their audience is not chosen by where they happen to stand. - One account type in one kind of entity = one screen: two rows of the same type naming the same kind of entity are refused on the same surface, because the second is a screen nobody standing there would reach. The two surfaces are independent: you may narrow okta-web and not the Okta app.
- The key is
<role>@<types>(tenant-admin@complex+education_company), derived by the editor and byset_account_typesbecause one type is now two rows. Withset_account_types, pass the bare role or the derived key. - The entity's type is read from its record, never from the request: no header or parameter changes which screen opens. An entity whose type the platform does not know (a record older than the vocabulary) is reached by the general row only.
- No contract bump, no Okta-app update: the server picks, and the phone
receives one entry exactly as it does today. Each narrowed row has its own
entry, floor (
min_contract) andversions[]rows. - A permission holder (someone who gets in through one of your permissions rather than their role) is judged in the entity they stand in: a row for complexes does not take a school's only screen away.
The simulator runs on a «school» sandbox, so it mounts the school's entry and lists the narrowed rows by name. Try the complex's screen on a sandbox of type «Educational Complex» (the type is chosen when the sandbox is set up) and sign in to it from a phone.
The trap: publishes cleanly, then never works
A portal key inside roles[]. "roles": ["student"] looks perfectly
fine — student is a known catalog key — but the platform matches a portal
user on the portal field alone and never on role names. So the
declaration matches nobody: the version publishes, the manifest looks healthy,
and then every page of that audience is refused for every user. And
"custom": true does not rescue it — a portal is not a tenant-defined
role, and the platform rejects custom on a portal audience outright.
An invented or mistyped role key ("staff", say) fails the same silent
way. Entities define their own roles, so a key outside the catalog is allowed —
but only when declared as such: "custom": true. Without it, it is refused
on purpose, because the alternative is finding out from a school's complaint
rather than from a publish error.
Enforcement is real, not concealment
EnsureAppAudience is applied to the entire web group — with no cooperation
from you — and to Livewire round-trips as well, via persistent middleware
that re-checks against the originating page's route. Guarding the page alone
would have left the windows open: every action after render is a POST to a
different route.
And the guard refuses by default. Before it, the launcher merely hid the link, and anyone who knew the path and typed it into the address bar was served.
Who is looking at your app right now — the user's ULID
Knowing the account type is not enough: your app needs to know which person, to show them what is theirs — this teacher's classes, this guardian's children.
use App\Services\PartnerApi\Identity\GetCurrentViewer;
$viewer = app(GetCurrentViewer::class)();
if ($viewer === null) {
return; // nobody of the three types is looking.
}
$viewer->type; // employee | student | guardian
$viewer->id; // ULID — this is what you store and join on
$viewer->displayName;
$viewer->roles; // raw role keys — employees only
$viewer->entityId; // the entity's ULID
$viewer->accountType; // tenant_admin | administrator | teacher | employee | student | guardian
$viewer->groups; // [{key, title}] administrator groups the school made — for display
$viewer->hasStaffRecord(); // does the entity have them in its staff directory?
Staff with no row in the staff directory — hasStaffRecord()
Every entity has staff it never entered as staff: the account that
registered the school (nobody adds the owner to the directory), a member
attached straight to the entity, and both sandbox accounts you develop
against. For them you get a full viewer — account type, groups, permissions,
entity — but id comes back empty and hasStaffRecord() answers false,
because there is no directory row to join to.
So read it before joining on id; can() and accountType you ask
without a second thought. (Before this they were all answered null, which
made the API dead on the sandbox itself.)
Do not use auth()->id() for this
The scanner does not stop you, but it is an internal numeric platform key, and the contract is that numeric ids never cross the partner boundary. More to the point, it is useless to you: it is the same integer whether the person entered as a teacher or as a guardian, so it matches nothing you hold.
id joins against the read services — that is the whole value
| Type | id is the same as |
|---|---|
student |
id on StudentDto |
employee |
id on EmployeeDto |
guardian |
id on GuardianDto |
So the join is direct, with no translation:
$mine = MyAppRecord::query()->where('student_ulid', $viewer->id)->get();
Note that those identifiers come from different tables, deliberately: a student is identified by their enrolment row, a guardian by their user record. Don't try to derive the identifier yourself from anything else — read it here.
type is the identity they entered as, not everything they are
One human can be a teacher at the school and the parent of a pupil in it. They enter as one, and this tells you which — the same identity the audience guard used when it decided to serve the page. An app deciding by "is this person a guardian somewhere?" will show a teacher the parent view.
null is not an error
It means nobody is signed in, or the viewer is none of the three types — a platform operator opening your page is exactly that. Render a sensible empty state, not an error.
None of it is read from the request
The identity is resolved server-side from the session context. Never accept a user id from the front end or a query string: a viewer the caller can name is a viewer the caller can forge.
The account type is one thing; what the school granted is another
This is the most-confused point in the integration, so it is said outright. Two people can look the same in front of your screen: an administrator, and somebody granted permissions under the title "HR manager". The first is an account type — the platform's word, meaning the same thing at every school. The second is a permission group this school created in its roles editor, named as it pleased, holding whatever permissions it chose — and the next school calls the same job something else, or does not have it.
| Account type | Permission group / permission | |
|---|---|---|
| Decided by | The platform — a closed vocabulary | The school — invented and named by it |
| Values | tenant_admin (runs the entity), administrator, teacher, student, guardian |
"HR manager", "Attendance supervisor"… unbounded |
| How many per person | One — a member of staff is a teacher OR an administrator, and either is an employee already | Any number, on top of the account type |
| Where you read it | $viewer->accountType |
$viewer->groups for display, $viewer->can() for the decision |
| What you build on it | The screen — family portal versus staff surface, and the audiences[] declaration |
The action — may they see this button, may they save this |
A group is neither an account nor an account type
A person signs in with one account. A permission group creates no account
and no account type; it adds permissions on top of what they already hold. The
accountant signs in with her own account, her account type is administrator
like any other administrator, and the group added what it added. So do not ask
«which account did they sign in with?» — ask «why do they hold this
permission?», and the table below answers it.
That is why the DTO carries two fields rather than one, each with its own question:
$viewer->type |
$viewer->accountType |
|
|---|---|---|
| Answers | The identity they entered as | The fixed account type |
| Values | employee · student · guardian |
tenant_admin · administrator · teacher · employee · student · guardian |
| Used for | The big split: family portal or staff surface (isStaff() / isPortal()) |
Which staff surface, and the audiences[] declaration |
With real people:
| Person | type |
accountType |
|---|---|---|
| The head of the school | employee |
tenant_admin |
| An administrator | employee |
administrator |
| An administrator in the "Accounting" group | employee |
administrator — the group does not change it |
| A teacher | employee |
teacher |
| Staff whose type was never recorded | employee |
employee |
| A student | student |
student |
| A guardian | guardian |
guardian |
tenant_adminoutranks the rest: whoever runs the entity stays that, whatever else they hold.employeeis not a third staff type: it is a legacy row nobody ever typed. Treat them as general staff and show what their permissions allow, not as a special class.- And the role they entered by is a third thing again: the context page
offers one card per role the person holds in the entity — a permission group
is a role row, so it is a card like any other — and they press one, which
the platform then prints under their name for the whole session. Read it in
$viewer->activeRole; see «The door they came in by» below, and do not confuse it with the account type: the first changes between two sessions of the same person, the second does not.
The permission, not the role's name — the functions that tell them apart
The problem these exist for: an app that branches on "administrator" shows
the management screens to everyone whose account type is administrator —
including the accountant the school put in a group it named "Accounting" and
handed one permission. And the app cannot branch on "Accounting", because
the next school calls it "Finance" or does not have it. The only answer
that is stable across schools is the permission, so ViewerDto now carries
the permissions of your app this person holds in this entity — read from
the tables, not the session, so it answers the same on the web, on mobile
and inside a queued job — together with the source of each one:
| Function | Answers |
|---|---|
$viewer->permissions |
Your app's permissions they hold (<feature>.<resource>.<action>) |
$viewer->can('x.y.view') / canAny([...]) / canAll([...]) |
Do they hold it — what every button and inner screen is built on |
$viewer->accessLevel() |
full (runs the entity) · granted (holds something of your app) · none |
$viewer->isTenantAdmin() / isStaff() / isPortal() / hasGroups() |
Who they are, with no string comparison |
$viewer->permissionSources |
Map: permission → the roles carrying it (administrator base role, group.accounting permission group, direct grant) |
$viewer->grantedBy('x.y.view') |
Why they hold it — which roles |
$viewer->grantedByBaseRole('x.y.view') |
Held because they are an administrator/teacher (the base-role grant from rbac_permissions) |
$viewer->grantedByGroupOnly('x.y.view') |
Held only because the school gave it to their group, or to them directly |
$viewer->groupOnlyPermissions() |
What this school added on top of the account type |
ViewerDto::isBaseRole($key) / isGroupRole($key) |
Is this key a platform role or a school group |
Group keys: a permission group's key is
group.<slug>. It used to beadministrator.<slug>, which collided with the ACCOUNT TYPEadministrator— a person came back as an administrator whose group was also an administrator, and no reader of roles could say which sense was meant. Existing groups were renamed andisGroupRole()accepts both spellings, so ask the helper rather than comparing the prefix yourself.
How to design your screen on that — "the administrator sees their profile, Accounting sees management":
- Declare your permissions in
rbac_permissionsand give the base role the least it needs; what you want in the school's hands, do not grant to a base role:
The school opens its roles editor and puts"rbac_permissions": { "administrator": ["okta_hr.profile.view"], "tenant-admin": ["okta_hr.profile.view", "okta_hr.management.view"] }okta_hr.management.viewinto a group called "Accounting" — or "Finance" — under the title you wrote inrbac_labels. - Check the permission, not the role:
The accountant — an$viewer = app(GetCurrentViewer::class)(); if ($viewer->isPortal()) { return view('okta-hr::family'); // the screen: account type } $showProfile = $viewer->can('okta_hr.profile.view'); // the administrator (base grant) $showManagement = $viewer->can('okta_hr.management.view'); // "Accounting", "Finance", or the tenant admin if ($viewer->accessLevel() === 'none') { return view('okta-hr::no-access'); }administratortoo — sees their profile and management: the first because they are an administrator, the second because their school said so. If you want a view of what the school added alone,groupOnlyPermissions()gives you that; but never hide what a person rightly holds because its source is the base role. - Never compare
$viewer->groups[*]['key'],['title']or$viewer->rolesagainst text. The scanner will not catch all of them; the second school will.
auth()->user()->can() depends on the guard: it answers false on any request
with no user bound to one — which is any mobile route without
app.installation. With the middleware it does answer, but $viewer->can()
stays the one to ask: it answers the same on the web, on mobile and in a
queued job, and does not turn on a guard your app does not control.
If
permissionsandpermissionSourcescome back empty for somebody who plainly holds them — especially ifauth()->user()->can()says yes while$viewer->can()says no — ask$viewer->hasAppContext()before touching the grants.The list is filtered to your app, so if the request did not carry the app context there is no «your app» and the list is empty by construction, whatever the person holds — indistinguishable from somebody who holds nothing, which is exactly what kept sending people to inspect grants that were fine.
accessLevel() === 'none'is then not a verdict about the person: nothing was asked about them.
hasAppContext()false ⇒ the route carries no middleware that builds the context — and which middleware that is differs by surface; see the table below. True with an empty list ⇒ it really is the grants: ask the platform team forphp artisan partners:viewer-permissions <slug> --user=<identifier> --tenant=<id>.
Which middleware builds the context — web is one thing, tokens another
module.context:<slug> is web-only. It reads the entity from
Tenant::current() (or the portal's entity from the session), and a token call
carries no session and therefore no entity — so it answers 403
no_tenant_context before your route is reached. Putting it on a mobile route
group stops every call your mini-app makes. (This passage used to say «put it on
the page route and on everything Livewire serves from it» without saying it was
web-only — which is exactly what one team did, and it took their app down.)
| Surface | What authenticates | Middleware |
|---|---|---|
| Your module's web pages (Livewire) | The user's session | module.context:<slug> — on the page route and on everything Livewire serves from it |
| Your endpoints called by your mini-app on mobile | The user's token (Sanctum) | app.installation |
| Your own server, off-platform | An installation bearer token | app.installation |
// The mobile route group — no module.context here
Route::middleware(['api', 'app.installation'])
->prefix('api/__MODULE_SLUG__')
->group(function () {
Route::get('/me', [MeController::class, 'show']);
});
No header for you to write: on every call your mini-app makes, the host
sends X-Module-Slug, X-Okta-Tenant-Id and X-Okta-Role-Id beside the user's
token, and app.installation resolves the installation from (entity, slug)
server-side, re-checks the app card's gate for the active role, and sets the
ModuleContext. GetCurrentViewer then works in full on mobile: accountType,
permissions, can(), hasAppContext() and activeRole.
The token that reaches you is a USER token, not an installation token. An installation token is a tenant-wide server credential and never ships to a phone. So identity comes from the user and authority from the installation — your mini-app never gains more than your app was granted.
So, in practice:
- Never branch on a group's key or title.
group.hrand "HR manager" are one school's words. An app that writesif ($viewer->groups[0]['title'] === 'HR manager')works at one school and goes quiet at the next. The group reaches you so you can show it beside the name, nothing more. - What a group buys is answered by
can()alone. The school put__MODULE_LOWER__.reports.viewinto "HR manager"? Ask about the permission, not the group — and the answer keeps working at the school that gave the same permission to a group called "Deputy". - The account type is one and fixed, so build the screen on it and nothing
else. A member of staff is a teacher or an administrator — not both — and
whoever runs the entity outranks either;
employeearrives only for a legacy row nobody ever typed, so treat it as staff without a type rather than as a third type.
$viewer = app(GetCurrentViewer::class)();
// The screen: account type
if ($viewer->accountType === 'guardian') {
return view('__MODULE_SLUG__::family');
}
// The action: the permission — not the group, not the role
$canApprove = $viewer->can('__MODULE_LOWER__.leave_requests.decide');
// Why they hold it — when it matters: a base grant, or something the school added
$viewer->grantedByGroupOnly('__MODULE_LOWER__.leave_requests.decide');
// Display only: what the school calls them
$badge = $viewer->groups[0]['title'] ?? null; // "HR manager"
The door they came in by — activeRole
The three fields above are about the person: roles is everything they
hold in the entity, permissions is everything those roles carry. This one is
about the session: the single role they pressed on the context page, which
the platform then prints under their name — «Administrator» one time,
«Accounting» the next, because a permission group is a role row and therefore a
door like any other.
| Field / function | Answers |
|---|---|
$viewer->activeRole |
['key' => 'group.accounting', 'name' => 'group.accounting', 'title' => 'Accounting'] — or null |
$viewer->enteredAs() |
The door's key, in the same shape as roles[] in your audiences, so it compares directly |
$viewer->enteredAsTitle() |
Its label as the platform prints it — for display only, never compare it |
$viewer->enteredAsBaseRole() / enteredAsGroup() |
Entered by a platform role, or by a group the school invented |
$viewer->activePermissions |
The slice of permissions that door carries — or null |
$viewer->activeCan('x.y.view') / activeCanAny([...]) |
Does this door carry it |
$viewer = app(GetCurrentViewer::class)();
// The surface follows the door: the administrator who is also in «Accounting»
// gets the staff screen when they entered as an administrator, and the finance
// screen when they entered as the accountant.
if ($viewer->activeCan('okta_hr.management.view')) {
return view('okta-hr::management');
}
return view('okta-hr::staff');
Three rules govern it, and each was written because its opposite breaks something:
- It segments; it does not authorise. Entering by a base role takes
nothing away: the platform's own gate does not narrow to the door, so
can()still answers on everything the person holds here. UseactiveCan()to decide what is shown and keepcan()for what may be done — an app that refuses an action because the door does not carry it disagrees with the platform about the same person in the same second, and refuses on the web what it allows on the phone. nullmeans «no door», not «no role». Where no door is recorded on the request both fields arenull, andactiveCan()falls back tocan()— so a screen built on it does not go blank. Do not write?? falsearound it, and never readnullas a refusal.- Never accept it from the client. Do not send «the selected role» in a request body or a header, and do not keep it in the browser: the platform reads it from server-side context only, and a role the caller can name is a role the caller can forge.
The door reaches you on mobile too. The phone picks a role at sign-in and
sends it on every call (X-Okta-Role-Id), and app.installation verifies it
is a role the caller holds in this entity — the app card's gate already turns
on it — before handing it to the viewer. So it is not a claim from the client: a
role they name but do not hold never gets through, and one they do hold can only
narrow what you draw. can() is unchanged either way.
This section used to say
activeRolewasnullon mobile and that «mobile stays on the union». The role was being verified and then discarded; it now arrives, so the one surface with nocan()is no longer also the one with no door.
And impersonation («browse on behalf of»): activeRole is the
impersonated person's — the platform rebuilds the whole context for them,
so the session is theirs and so is the door. But note: in impersonation — and
when signing into the sandbox — nobody pressed a card, so the platform
takes the first of their roles. It is a role they genuinely hold, but it is not
«what they chose»; one more reason not to make activeCan() a gate on actions.
The difference between two doors must stay a difference of display.
A ready-made prompt for your assistant (Claude / Cursor)
Paste it as-is into the AI assistant inside your app's repository, to review the code against this rule and fix what breaks it:
Review our app against the Okta platform rule "the account type is one thing,
what the school granted is another", and fix whatever breaks it:
Two people can look the same in front of the screen: an administrator, and
somebody granted permissions under the title "HR manager". The first is an
account type — the platform's word, meaning the same thing at every school.
The second is an administrator group this school created and named as it
pleased; the next school calls the same job something else or does not have it.
We read both from GetCurrentViewer:
- $viewer->accountType — the platform's fixed vocabulary, one per person:
tenant_admin (runs the entity, outranks the rest) | administrator | teacher |
student | guardian — and employee only for a legacy row nobody ever typed.
A member of staff is a teacher OR an administrator, never both.
- $viewer->groups — [{key, title}] the administrator groups the school made,
with its titles. For display beside the name only.
Required:
1. Screens are built on accountType alone: any "family portal or staff
surface" branch, and any audiences[] declaration, reads it from here — not
from the raw $viewer->roles and not from a role name.
2. Actions are built on the permission alone: may they see this button, may
they save → $viewer->can('__MODULE_LOWER__.<resource>.<action>') (answers
the same on web and mobile; auth()->user()->can() is false without a
session). Never "are they an administrator", never "are they in group X".
Helpers: canAny/canAll, accessLevel() (full|granted|none),
isTenantAdmin()/isStaff()/isPortal(), and
grantedByGroupOnly()/grantedByBaseRole()/permissionSources when WHY they
hold it matters — the base-role grant from rbac_permissions, or something
this school added to a group. Grant the base role the least it needs and
leave what you want in the school's hands to its groups.
3. Never branch on a group's key or title. Search the code for any comparison
against text such as 'HR manager' or 'group.hr' or
$viewer->groups[...]['key'|'title'] inside an if/match, and replace it with
can() on the permission it actually means — and declare that permission in
manifest.json → rbac_permissions with feature = __MODULE_LOWER__, and its
title in rbac_labels.
4. Do not treat employee as a third type: it is staff with no recorded type.
Show them the general surface and whatever their permissions allow.
5. Never assume a permission holder is an administrator, nor that an
administrator holds a permission: show what the permissions allow and hide
the rest instead of answering 403.
6. If you want the surface to follow the role the person entered by («
Administrator» one session, «Accounting» the next), use
$viewer->activeCan()/enteredAs()/enteredAsGroup() for DISPLAY only and keep
can() for every refusal. activeRole is null on token requests and
activeCan() falls back to can() there — so do not write ?? false around it
and never read null as a refusal.
The correct shape:
$viewer = app(GetCurrentViewer::class)();
if ($viewer->accountType === 'guardian') return view('__MODULE_SLUG__::family'); // screen
$canApprove = auth()->user()->can('__MODULE_LOWER__.leave_requests.decide'); // action
$badge = $viewer->groups[0]['title'] ?? null; // display only
Reference: the developer guide, "Platform accounts" chapter → "The account type
is one thing; what the school granted is another" — read it with
read_guide/search_guide before editing, then run scan_code and
validate_manifest on what you changed.
What the declaration does to your app's visibility
The declaration governs more than routes — it governs whether your app appears in the launcher at all:
- Declared no audiences? Your tile shows to everyone in the entity, as it always has.
- Declared audiences and the user matches one? The tile shows, linking to that audience's route.
- Declared audiences and the user matches none? No tile at all.
The third case is deliberate: a tile opening a page the guard will refuse is worse than no tile, because the user reads it as a permissions failure in the platform rather than an app that was never theirs. So if your app disappears for an account type you expected to see it, the fault is in your declaration, not in the entity's install.
The second axis: whoever holds one of your permissions gets in
The account type is not the only axis, and you must not build on it alone.
The role names you write in roles[] are your guess at what schools call
things; an entity invents its own roles ("Attendance supervisor", "Deputy for
affairs") and does not ask you. So an app declaring
roles: ["tenant-admin", "teacher"] vanishes for an employee the school gave
all twelve of its permissions — because the two axes never met.
So: whoever holds any permission from your app's rbac_permissions reaches
your general surface — a tile in the launcher, an allow from the guard, and
a card in the mobile catalog. The entity granting the permission is the
entity saying that employee uses your app.
And the two axes stay separate, which is the condition:
| Axis | Decided by | Fixed? | What it governs |
|---|---|---|---|
audiences[] |
You, in the manifest | Yes | A screen per account type — which route each one opens |
rbac_permissions |
The entity, in its roles editor | No | What may be done inside your app |
A permission opens menu.route (your general surface) and never another
audience's screen: someone you did not declare for school-app.admin does
not get in, however many of your permissions they hold. Two cases, and only
two, open nothing:
- A portal audience owns
menu.route(student or guardian): a staff permission is never a way onto the family screen, whatever the school granted. - Your staff screens are genuinely split — two or more different routes —
and
menu.routeis one of them: getting in would mean landing on a screen built for somebody else.
Every staff audience pointing at the same route is not a split. An app
declaring one audience (tenant-admin → app.dashboard) where menu.route
is that very route has one staff screen; the audience says who meets it by
default, not that it belongs to that role alone, and a permission holder
reaches it. That is the shape of most apps, and the case where this rule works
without you changing anything in your manifest.
What this means for you in practice:
- Guard your screens with
can(), not by assuming a role.auth()->user()->can('__MODULE_LOWER__.orders.view')is the right question; "is this user a tenant-admin?" is one that answers wrongly at the first school that names its roles differently than you expected. - Declare
rbac_permissionsfor every real action in your app (see Role permissions inside your app) — they are now the way in, not just a description of what may be done. - Keep one staff surface that receives whoever entered by permission, then
branch from it with
can()— either amenu.routeno audience owns, or a single staff screen every one of your audiences points at. An app that splits its staff across different routes shuts its door on every role you did not foresee.
Today the guard logs; it does not yet refuse
PARTNER_AUDIENCE_GUARD_MODE currently defaults to log: the guard records
the violation and serves the page. Which means a wrong declaration — a portal
key inside roles[], or an invented role without custom — looks like it
works today, and stops working entirely the moment that value is flipped to
enforce.
So do not test your declaration against today's behaviour. Test it under enforcement:
config(['partners.audience_guard.mode' => 'enforce']);
And the rule there is deny by default inside a segmented app: the moment you
declare audiences, every route no audience covers is refused. A page you
forgot to declare does not stay open — it closes. Inventory your routes before
you declare your first audience.
Note: hiding the tile in the launcher works now, without waiting for the mode to flip, because it is display filtering rather than a guard. So you may well see your app hidden from a user who can still open its route by typing it — that gap is precisely the difference between
logandenforce.