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.
  • expiresAt is when the delegation ends (null for one that does not) — show it: "ends Thursday" explains the button's disappearance the day after.
  • mayForChild is 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 answers false, 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> with feature = your slug in snake_case (okta-hdor → okta_hdor) — the same rule rbac_permissions carries, 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.ar is required — it is the whole of what a parent reads beside the checkbox, and an unlabelled box is one nobody can tick honestly. label.en falls 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) or dependent.
  • Exactly one target: roles (roles inside the entity) or portal.
  • At least one surface: web_route and/or mobile_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 in rbac_permissions too — 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[] or portal (a portal account holds no permission in the entity at all), and it needs web_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_entry on 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 roles and permissions. 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 by set_account_types because one type is now two rows. With set_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) and versions[] 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_admin outranks the rest: whoever runs the entity stays that, whatever else they hold.
  • employee is 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 be administrator.<slug>, which collided with the ACCOUNT TYPE administrator — 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 and isGroupRole() 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":

  1. Declare your permissions in rbac_permissions and give the base role the least it needs; what you want in the school's hands, do not grant to a base role:
    "rbac_permissions": {
      "administrator": ["okta_hr.profile.view"],
      "tenant-admin":  ["okta_hr.profile.view", "okta_hr.management.view"]
    }
    
    The school opens its roles editor and puts okta_hr.management.view into a group called "Accounting" — or "Finance" — under the title you wrote in rbac_labels.
  2. Check the permission, not the role:
    $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');
    }
    
    The accountant — an administrator too — 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.
  3. Never compare $viewer->groups[*]['key'], ['title'] or $viewer->roles against 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 permissions and permissionSources come back empty for somebody who plainly holds them — especially if auth()->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 for php 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.hr and "HR manager" are one school's words. An app that writes if ($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.view into "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; employee arrives 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. Use activeCan() to decide what is shown and keep can() 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.
  • null means «no door», not «no role». Where no door is recorded on the request both fields are null, and activeCan() falls back to can() — so a screen built on it does not go blank. Do not write ?? false around it, and never read null as 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 activeRole was null on mobile and that «mobile stays on the union». The role was being verified and then discarded; it now arrives, so the one surface with no can() 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.route is 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_permissions for 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 a menu.route no 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 log and enforce.

View the whole guide on one page · ↧ Raw Markdown