Getting started

Companies and complexes: the tree and its schools' data

Scopes answer «which data»; the previous chapter answers «who is sitting there». A third question reaches neither: what kind of place is your app running in, and what sits above and beneath it.

A tenant is not always a school. An education company owns complexes, a complex holds schools — and the same app may be installed at any of the three levels. On a school your app shows that school's pupils; at the company's head office it has no pupils of its own, and its job is to move across the schools beneath it. Before these endpoints /whoami gave an app a numeric tenant id and nothing else: a head-office app could not name its schools, and a school app could not tell it belonged to a complex at all.

In short — can a complex's app read its schools' data? Yes, straight from the mini-app. Name the schools on any data read: ?entities=<ulid>,<ulid> (an envelope keyed by school) or ?entity=<ulid> (one school's answer, as is). Three things are needed: your app holds entity.hierarchy.read, the data scope is granted on the complex or on the school — either is enough — and the person using the app holds tenants.children.view_members in the complex. The details are in «Reading the schools' data from a complex» below, and the full Dart example in the native mode chapter.

The tree has no fixed depth

This is the first thing apps get wrong. A school may sit under a complex under a company; under a complex that is itself the root with no company above; directly under a company with no complex between; or alone under nobody. One company may hold both shapes at once — a direct school and one inside a complex.

company ─┬─ complex ─┬─ school A
         │           └─ school B
         └─ school C            ← direct, no complex

standalone complex ─── school D   ← no company above

school E                          ← nobody above

So never write «the second ancestor is the company». Read the chain as it comes and check type.

Where am I? — GetCurrentEntity (no scope)

use App\Services\PartnerApi\Entity\Hierarchy\GetCurrentEntity;

$here = app(GetCurrentEntity::class)();

$here->id;           // the entity's ULID — the same `entityId` ViewerDto reports
$here->type;         // school | complex | education_company | university | …
$here->isContainer;  // may this type hold other entities?
$here->parentId;     // ULID of the entity directly above, or null
$here->students;     // held by THIS entity itself — never the tree's total

Unscoped on purpose, beside /whoami and the clock and for the same reason: the entity's own name and type are the frame your data is read in, not data a grant protects. The parent's name and the rest of the tree belong to other tenants and sit behind the scope below.

Start here. type decides which screen your app draws: isContainer true ⇒ the head-office screen (a list of entities); false ⇒ the school screen (pupils).

The one scope: entity.hierarchy.read

Unlocks the tree reads below, up and down, and means one thing: «this app may see the shape of the tree it is in» — who is beneath it, each one's profile, and who is above. A school that is a company's customer did not agree to be enumerated by every app the company installs on itself — so the entity's administrator grants this explicitly. There is no .write: the tree is drawn by the platform and the company's administrators, and an app that could re-parent a school is an app that could move pupils between customers.

It is also a condition for reading the children's data (below): an app that was not allowed to see the tree does not read its schools, whatever ULIDs it holds.

Down: who is beneath me? — ListChildEntities

use App\Services\PartnerApi\Entity\Hierarchy\ListChildEntities;

$direct = app(ListChildEntities::class)();                    // next level only
$all    = app(ListChildEntities::class)(descendants: true);   // every level

foreach ($all as $entity) {
    $entity->name;       // "Elite School"
    $entity->type;       // school
    $entity->parentId;   // its parent's ULID — rebuild the tree from the flat list
    $entity->students;   // its own pupils
    $entity->children;   // how many entities it holds (a complex)
    $entity->installed;  // whether your app has its own installation there
    $entity->access;     // ['installed' => …, 'scopes' => [...]] ← what can be read there
}

installed and access are what make the list actionable.

  • installed — whether your app has its own installation on that entity. That is still the only way to write into it, with the scopes its administrators granted.
  • access.scopes — the read scopes your app reads that entity with: what it granted your installation there, if any, plus the read grant your own entity gave, carried down. Either is enough — so a school that never installed your app is read with the complex's grant.

The list also says whether the person using the app right now may read the children: can_read_children on the /entity/children response, and app(CanReadChildEntities::class)() in embedded code. Read it before you draw a «download the schools' data» button, and do not draw it when false.

(Installing on many children at once is done by the company's administrator in okta-web: Child entities → Install app — each child gets its own installation and its own grant.)

Up: whom do I belong to? — GetEntityAncestors

use App\Services\PartnerApi\Entity\Hierarchy\GetEntityAncestors;

$chain = app(GetEntityAncestors::class)();   // nearest first; empty at the root

// a school under a complex under a company:
$chain[0]->type;  // complex           ← the complex
$chain[1]->type;  // education_company ← the company

// a school directly under a company:
$chain[0]->type;  // education_company — and there is no [1]

// a school alone:
$chain === [];

This decides real things in an app: whose letterhead goes on a report, whether a fee is set here or inherited from above, whether a notice came from the school or from head office. Search the chain for the type you want rather than relying on its position:

$company = collect($chain)->firstWhere('type', 'education_company');  // or null
$complex = collect($chain)->firstWhere('type', 'complex');            // or null

One school's profile — GET /entity/children/{id}

The list is a map; this is what you draw on a school's card:

{
  "id": "01HN…A", "name": "Elite School", "type": "school", "parent_id": "01HN…X",
  "status": "active", "students": 412, "employees": 38, "children": 0,
  "grades": 6, "sections": 18,
  "logo": "/api/apps/entity/children/01HN…A/logo",
  "phone": "0112345678", "email": "info@…", "website": null, "address": "Al Malqa, Riyadh",
  "country": { "code": "SA", "name": "Saudi Arabia" },
  "timezone": "Asia/Riyadh",
  "stage": { "id": 3, "country_id": 1, "name_ar": "الابتدائية", "name_en": "Primary", "order": 1, "is_active": true },
  "installed": false,
  "access": { "installed": false, "scopes": ["education.students.read", "entity.hierarchy.read"] },
  "can_read_children": true
}
  • The logo is a path, not a URL, like a pupil's photograph: a mini-app cannot fetch a URL itself, so show it with Okta.remoteImage(school['logo'], 48). null when there is no logo.
  • country.code is the code /countries answers with, and stage is the very object /countries/education-levels lists — match by id, not name.
  • Counts are the school's own, like everywhere on this surface — never a tree's sum.
  • A school not beneath you, and a ULID that names nothing: one 404 — entity_not_found.

Reading the schools' data from a complex — ?entities= and ?entity=

Your app is open in a complex, and the pupils are in its schools. The data routes themselves answer for the schools beneath when you name them — no server code, straight from the mini-app:

final r = await Okta.getQuery('/api/apps/education/students', {
  'entities': '$schoolA,$schoolB',   // up to 20 schools per call
  'per_page': '100',
});

The answer is wrapped by school, and each key holds exactly the answer the route gives that school — read it with the same code you read your own entity's answer with:

{
  "entities": {
    "01HN…A": { "data": [ … ], "total": 412, "per_page": 100, "current_page": 1, "last_page": 5 },
    "01HN…B": { "data": [ … ], "total": 88, "per_page": 100, "current_page": 1, "last_page": 1 },
    "01HN…C": { "error": "missing_scope", "status": 403, "scope": "education.guardians.read", "message": "…" }
  }
}

Which grant reads a school? Either of two, and either is enough: what the school granted your installation there, or the read grant your entity (the complex or company) gave, carried down. So a school that never installed your app, removed it, or did not grant the scope, is read as long as the complex granted it. Two limits:

  • Only scopes ending .read travel down. Writing into a school still takes that school's own grant: ActInChildEntity for a person operating the complex's managed copy (below), RunAsChildEntity in a job, or that school's own token for your server.
  • They travel for a person. Whoever is using the app must hold the unified-registry permission (tenants.children.view_members) in the complex — else 403 children_not_readable. Your server with an installation token never reads this way (403 entities_need_a_person): it reads each school with that school's own token, as before.

How the answer behaves:

  • Pagination is per school: page and per_page apply to each key. For a school with pages left, ask it alone: ?entities=01HN…A&page=2.
  • A refused school is one key, not the response: its key carries error and status (and scope when a scope is missing), the rest arrives, and the response is 200.
  • entity_not_found for a school not beneath you and for a ULID that names nothing — the same answer on purpose, so the envelope cannot be used to learn what exists.
  • Your own entity may be named in the list, and its data arrives under its key.
  • A filter that belongs to one school (section_id, say) is sent as-is to every school, and the others answer an empty page, not an error.

?entity=<ulid> for one school: the answer is that school's answer as is — no envelope, same status and headers. A pupil's photograph is only read this way, because a file cannot sit inside an envelope:

Okta.remoteImage('/api/apps/education/students/$id/photo?entity=$school', 64);

Refused by name, never ignored (ignoring it would answer with the complex's own data looking like success):

Code Status When
entities_read_only 422 any write — this is a download
entities_not_supported 422 a route that answers your own entity: whoami, the clock, the tree, payments, notifications — or entities on a file route
too_many_entities 422 more than 20
entity_and_entities / invalid_entities 422 both at once, or an empty list
missing_scope (entity.hierarchy.read) 403 your app does not hold the tree scope
children_not_readable 403 the person does not hold tenants.children.view_members, or came in through a narrowed door (a guardian's delegate)
entities_need_a_person 403 an installation token (a server) or a device with no person

It works on every read of an entity's data: education/* (pupils, photos, face templates, guardians, subjects, curriculum, grades, sections, years, terms), employees/directory, tool-keys/* and reports/*. No minContract bump: the parameter is part of the path, and Okta.get/Okta.getQuery carry it on every host.

Acting inside a child — RunAsChildEntity (embedded only)

The embedded counterpart of ?entities=: it steps into a child and runs your code there, so every PartnerApi service inside behaves as if your app were installed on that school. The same rule: inside the school your app holds what the school granted its installation there, plus the read grant from your entity carried down when the one asking is a person holding tenants.children.view_members. Writes take the school's own grant only.

use App\Services\PartnerApi\Entity\Hierarchy\ListChildEntities;
use App\Services\PartnerApi\Entity\Hierarchy\RunAsChildEntity;
use App\Services\PartnerApi\Education\Students\ListStudents;

$children = app(ListChildEntities::class)(descendants: true);

foreach ($children as $school) {
    if ($school->type !== 'school' || ! in_array('education.students.read', $school->access['scopes'], true)) {
        continue;   // not a school, or no grant reads its pupils
    }

    $students = app(RunAsChildEntity::class)($school->id, function () {
        // In here you ARE "an app on this school": Tenant::current() is the
        // school, the scopes are its own grant plus your entity's reads.
        return app(ListStudents::class)(perPage: 100)->data;
    });
}

Checked, in order: (1) you hold entity.hierarchy.read; (2) the target is a descendant of your entity at any depth — a sibling, a parent or a stranger is refused as «not found», never «forbidden», so the error cannot be used to probe which tenants exist; (3) the call is not made from inside a complex's act — else ChildActRefusedException (already_acting); (4) the asking context was not narrowed on purpose — a screen pinned to classrooms or grades, a guardian's delegate, or an installation token with a signed-in person on the same request — else ChildActRefusedException (narrowed); (5) there is a grant to act under: the same app's active installation on the target, or — for a person holding the permission — the read grant from your entity, else MissingScopeException with the key entity.child_installation. The current tenant and context are restored unconditionally after the closure, even when it throws — your request must not go on running as the school on the next line.

With no person, your entity's grant does not travel down. A scheduled task, a queue, a server: nobody is asking, so inside the school your app holds what the school granted, only, and a school that never installed it is refused as before. With a person holding the permission, your entity's reads travel down — never its writes.

Not on a request path. It asks nothing about the person where it matters: a school that installed your app is entered under that school's own grant — writes included — whoever is asking, even somebody the complex never let into its schools. The person check above decides only whether your entity's reads are added on top of that grant. Keep it for scheduled tasks, queues and servers. On a request path a person reads the schools with ?entities= (or the tree reads) and writes with ActInChildEntity — «Your app works inside a complex's schools» below.

External apps: one token per entity

An external app's server already holds one installation token per tenant: it reads the tree from the entity it is in, then reaches a child with that child's token on the same API, and installed tells it whether such a token can exist. ?entities= is not for it: a token has no person behind it, so it is answered 403 entities_need_a_person. The surfaces of your app that a user opens (a mini-app, a web page) use ?entities= like any other.

Endpoint Scope Answers
GET /entity (none) where am I: type, is_container, parent_id
GET /entity/children?descendants=1 entity.hierarchy.read who is beneath me, with installed and access per entity, and can_read_children
GET /entity/children/{id} entity.hierarchy.read one child's profile: logo, contact, country, stage, sizes, access
GET /entity/children/{id}/logo entity.hierarchy.read its logo — as a file, 404 when there is none
GET /entity/ancestors entity.hierarchy.read whom I belong to, nearest first
any data read with ?entities= / ?entity= the route's scope + entity.hierarchy.read the children's data — for a person holding tenants.children.view_members

How an app declares «supports companies and complexes»

Working on a container needs no manifest flag. What makes an app work there is three things in code: (1) check type/isContainer on open and draw the matching screen; (2) request entity.hierarchy.read in your scopes whenever you need the tree; (3) assume no depth. An app that does (1) alone keeps working on a school exactly as before, and at head office shows a screen that says what it is — instead of showing «no pupils» and looking broken.

The two declarations in this area answer different questions — not whether your app works on a container, but whether a container may put it in its schools so they cannot remove it (the next section), and whether a container installing it installs it in its schools too (the one after).

When the head-office screen is another app rather than a branch in the same screen, do not ship it to every school: declare a row for the account type narrowed by entity_types (chapter «Platform accounts», section «Entity type»), and the platform picks the package by the entity's type — the complex's package never reaches a school's phone.

A complex installs your app in its schools — hierarchy.managed_install

A complex (or an education company, or a university) can already install any store app in its schools from its «sub-entity apps» page — as each school's own copy, which the school removes whenever it likes. For some apps that is the wrong shape: the complex's attendance, its HR, its transport are decisions the complex takes for all its schools, and a school quietly removing one leaves a hole in the complex's picture that nobody notices until a report comes back short.

One flag in the manifest opts your app into the other shape:

{ "hierarchy": { "managed_install": true } }

With it on:

Who What happens
The complex Installs the app for itself first — its own copy is what manages the schools' copies. Then chooses, on the «sub-entity apps» page, which entities beneath it get it, at any depth (a company reaches its complexes and their schools alike), and whether every entity that joins it later — created under it or moved under it — gets it automatically.
Each chosen school Gets the app, and keeps everything about it: its settings, its notifications (a notification nobody at the school has configured follows the complex's setting), which of its staff may use it (the complex's operators also act inside it — «Your app works inside a complex's schools» below). It loses one thing — the uninstall button — and reads «installed by » in its place. The store API refuses the uninstall by name (403, error: managed_by_ancestor).
A school that already had your app Keeps it as it is — taking it over widens nothing; the complex becomes the one that manages it.
Removal The complex removes the app from one school on the same page, or from all of them at once by uninstalling its own copy. Either way it undoes what it did: a copy it installed is uninstalled; a copy the school had installed before is given back — still installed, unlocked.

Three things to know before you turn it on:

  • The complex approves every scope your app requests on each school's behalf — as the regular bulk install already does; taking over a copy the school installed itself widens nothing. And as on every install, each publish of yours sets every copy's scopes to what your manifest declares — the review is the platform's consent point. So request what the app needs and nothing more: on a managed install nobody at the school ticks the list.
  • One container at a time. A school's copy is held by one container; a company that already manages it there is not overridden by a complex beneath it — the complex's page says who holds it.
  • The flag governs new placements only. Turning it off in a later version stops complexes placing the app managed from then on, and stops the automatic placement in new schools; copies already placed stay managed until the complex removes them. Your publish never unlocks a school.

It is saved with the version: the «Integration» tab of the version editor, set_managed_install over MCP, or sync_from_manifest from your repo — keep the repo and the version in step, or the next sync writes the repo's value back. validate_manifest refuses a "1" or a 1: okta-web honours a literal true and nothing else.

Nothing in your code changes: a managed copy is an ordinary installation, with its own token, its own settings and the scopes approved for it.

A complex's install covers its schools — hierarchy.reach

The managed install above leaves the complex to choose the schools. For an app every school of the complex runs without exception — attendance, transport, dismissal — that choice is a step somebody forgets: the complex installs the app for itself, assumes it is now in its schools, and it is not. So you, as the developer, answer one question once: when a complex installs your app, what does the install cover?

reach What happens when a complex (an education company, a university) installs it
"container" — the complex only It is installed in the complex alone and serves the complex. This is what happens for every app that does not name the key, so saying it and leaving it out read the same.
"container_and_children" — the complex and its schools It is installed in the complex and, with it, in every entity beneath it at any depth as a managed copy (as in the previous section) the school cannot remove — with nobody choosing anything. Every entity that joins the complex later (created under it, or moved under it together with its schools) gets it automatically. The complex removing its own copy removes it from its schools.
{ "hierarchy": { "reach": "container_and_children" } }
  • It carries the managed install with it: "container_and_children" places managed copies, so it means managed_install: true whether you write it or not — and the complex can still remove it from one particular school on its sub-entity apps page, as in the previous section. "managed_install": false beside it is a contradiction validate_manifest and okta-web refuse by name.
  • Whoever presses «install» is asked about the schools too: the store dialog tells the complex, before the button, that the app will be installed in its entities and how many there are, and it refuses the install from a person without the «install apps in sub-entities» permission (or on a plan without it) — not a silent install on the complex alone. The store API refuses it with 403, error: reach_not_allowed.
  • A school that installed it before the complex keeps it as it is, and the complex becomes the one who manages it; a copy a container higher up manages (a company above the complex) is left alone. A company installing it reaches its complexes and their schools together, and a complex it is placed in does not claim its schools first.
  • The complex approves every scope your app requests on each school's behalf — and nobody at the school ticks the list. Request only what the app needs.
  • The choice governs installs from now on. A complex that installed an earlier version is not cascaded when you publish: your publish never installs anything in a school its complex did not decide on. That complex can place it in all its schools from its sub-entity apps page. The reverse too: going back to "container" stops new installs, and existing copies stay until the complex removes them.
  • Your app knows where it runs: the copy in a school is an ordinary install with the school's token, and the complex's copy reads its schools with ?entities= as at the start of this chapter — and writes in them through ActInChildEntity (next section).

It is saved with the version: the «Integration» tab of the version editor (the «When a complex installs your app, what does the install cover?» choice), set_install_reach over MCP, or sync_from_manifest from your repo — keep the repo and the version in step.

Your app works inside a complex's schools — ActInChildEntity

A complex that runs one gate for all its schools — arrivals at its door, dismissal, the bus — writes records about pupils who are enrolled in its schools, not in the complex. ?entities= reads those schools and never writes; RunAsChildEntity enters a school without asking anything about the person. ActInChildEntity is the path for a person operating the complex's copy of your app: the platform asks for their authority at the complex, then runs your code inside the pupil's school under that school's own copy of your app — so the record, and the notice to the pupil's parents, land in the school, with the school's settings.

In short: embedded apps only, in process — there is no HTTP route for it. The person holds two keys at the complex: tenants.children.operate_apps and a permission of your app. Only schools whose copy of your app the complex's own installation manages (hierarchy.managed_install / hierarchy.reach) open. The school a record lands in is derived from the pupil on the server, never taken from the request.

Who it is for

  • An embedded app, in process: services under App\Services\PartnerApi\…. There is no partner HTTP route for acting inside a school — nothing under /api/apps/*, nothing in the API reference.
  • A mini-app writes through your own endpoints (behind app.installation — chapter «Platform accounts», section «Which middleware builds the context»), and those endpoints call these services. It reads the schools with ?entities= as before, or through your endpoints that call the tree reads below.
  • An external app's server stays on one token per entity: it writes into a school with that school's own token.

Two keys, both held at the complex

  • tenants.children.operate_apps — «Operate apps inside child entities». A platform permission: the global tenant-admin role holds it, and the complex grants it to any other group that runs your app across its schools.
  • A permission of your app for the act — say my_app.arrivals.record — declared in your rbac_permissions or under your prefix. The operate permission is never accepted as "yours": the two keys stay two.

Either one alone is refused: your permission alone is usually what every administrator of the complex holds, and the operate key alone names no act. Both are read in the complex's own team, from the permission tables — not through can() (a platform operator's blanket yes does not count), and not in the school (a tenant-admin created inside the school by «Enter» holds nothing here). And both are narrowed by the role the person came in by — the card picked on the web, the X-Okta-Role-Id that app.installation verified on mobile; a permission granted to the person directly, not through a role, is not narrowed. If your mobile routes run a door of your own instead of app.installation, pass that header's value to VerifyEnteredRole before the first act (App\Services\PartnerApi\Identity\VerifyEnteredRole, __invoke(int $tenantId, ?int $claimedRoleId): void): for a role the person does not hold there it throws AccessDeniedHttpException (a 403, message entered_role_not_assigned), and without the call nothing narrows — the person acts with every role they hold at the complex.

Your app itself needs entity.hierarchy.read on the complex's copy.

Which schools open

Only a school whose active copy of your app is managed by the complex's own installation — placed or adopted through hierarchy.managed_install or hierarchy.reach (the two sections above). A copy the school installed itself that nobody adopted, a copy a company above the complex manages, a school with no copy, a suspended school: child_not_managed. It is asked again on every call, so a complex that uninstalls its copy, or a school moved out from under it, is closed from the next request.

Inside, your app holds the school's copy's grant and nothing else: no scope of the complex's is added, and there is no fallback to the complex's grant. Name the scopes your code needs in $requiredChildScopes; a school whose copy lacks one is refused school_grant_missing:<scope> before your code runs.

One school — ActInChildEntity

__invoke(string $childEntityUlid, string $appPermission, Closure $callback, array $requiredChildScopes = []): mixed — it returns what your closure returns.

Inside the closure Tenant::current() is the school; the module context is the school's copy (its installation, and the scopes the school approved for it); GetActiveEntityId() and GetCurrentEntity() answer the school; DispatchNotification sends under the school's copy. All of it is restored in finally, even when the closure throws.

The questions, in order — each word is the reason of App\Exceptions\ChildActRefusedException unless the line says otherwise:

  1. Your app holds entity.hierarchy.read — else MissingScopeException.
  2. The context is your app's own active installation at the entity it runs in — else not_the_app_root (a context carried down into a school is not a root).
  3. Not already inside an act — else already_acting: a school never acts on what is beneath it with the complex's authority.
  4. The permission is your app's — else InvalidArgumentException('app_permission_not_declared'); an empty or non-string entry in $requiredChildScopes is InvalidArgumentException('required_scope_invalid'). Bugs in your code, not answers about the person.
  5. That entity is a container — else not_container — and the ULID names a live entity beneath it — else child_entity_not_found, the same answer for a sibling, a parent, a stranger and a ULID that names nothing.
  6. The actor, at the complex: no_actor (nobody — a queued job, an installation token), narrowed (a door narrowed on purpose — a screen pinned to classrooms, a guardian's delegate), missing_permission (not a live member of the complex, or one of the two keys missing through the role they came in by), device_elsewhere (a panel paired to another entity).
  7. The school's copy is managed by the complex's installation — else child_not_managed.
  8. The school's own grant holds every scope in $requiredChildScopes — else school_grant_missing:<scope>.

Steps 1–4 are about your call, and are thrown before any school is looked at. From step 5 on, the platform writes one audit line (app-children-act) per call — per school, not per record — for every act and every refusal: who, through which role, which permission, which school, the outcome. Nothing is remembered between requests: revoking the operate permission or uninstalling the complex's copy takes effect on the next one.

Let the exception through and the answer is one of two: 404 {"error": "child_entity_not_found"}, or 403 {"error": "child_act_refused", "reason": "…"}. To branch on it, catch ChildActRefusedException (the policy scanner allows App\Exceptions\*) and read reason, childEntityUlid and isNotFound().

Many pupils — ActForStudentsInTree

__invoke(array $studentUlids, string $appPermission, Closure $perSchool, array $requiredChildScopes = []): ChildActBatch — your closure receives (string $entityUlid, array $studentUlids).

  • It places each pupil in the entity they are enrolled in — on the server, from their live enrolment — and runs your closure once per school through ActInChildEntity: two hundred records across five schools are five acts.
  • Pupils enrolled at the complex itself run in place, under the complex's own copy, with no switch — and with no two-key check and no audit line: they are your app's own entity, guarded by whatever guards your route. At a school (not a container) the call still works: that school's pupils run in place and everybody else is unplaced.
  • One school refusing never stops the others, and nothing for its pupils is written anywhere — a refused school never turns into a write under the complex.
  • ChildActBatch: results (entity ULID → what your closure returned), refused (entity ULID → the reason), unplaced (pupil ULIDs outside the complex, released, or naming nobody — one bucket on purpose), pupils (entity ULID → the pupils grouped there) and entityOf($studentUlid).
  • Thrown rather than recorded per school: MissingScopeException, not_the_app_root, already_acting and the InvalidArgumentExceptions — they are about your call. An exception from your own closure is yours: it is not a refusal, and it propagates.
use App\Services\PartnerApi\Entity\Hierarchy\ActForStudentsInTree;
use App\Services\PartnerApi\Notifications\DispatchNotification;
use Modules\MyApp\Models\Arrival;

// The gate sends pupil ULIDs — and nothing about their schools.
$pupils = $request->input('students', []);

$batch = app(ActForStudentsInTree::class)(
    $pupils,
    'my_app.arrivals.record',        // your permission, held at the complex beside operate_apps
    function (string $schoolUlid, array $schoolPupils): int {
        // Once per school, inside it: the tenant, your module context and the
        // notification settings are that school's.
        foreach ($schoolPupils as $pupilUlid) {
            Arrival::create([
                'school_ulid' => $schoolUlid,   // where the pupil is enrolled — decided by the platform
                'student_ulid' => $pupilUlid,
                'arrived_at' => now(),
            ]);

            app(DispatchNotification::class)('my-app.arrival.recorded', [
                'recipient' => ['type' => 'parent_of_student', 'id' => $pupilUlid],
            ]);
        }

        return count($schoolPupils);
    },
    ['education.students.read'],     // each school's OWN copy must hold these
);

$batch->results;    // ['01HN…A' => 12, '01HN…B' => 3]
$batch->refused;    // ['01HN…C' => 'child_not_managed'] — nothing was written for those pupils
$batch->unplaced;   // outside the complex, released, or unknown

Reading across the tree — without entering a school

The tree reads run at the complex and never switch. Each one asks the same two keys (your permission here is the read's — say my_app.arrivals.view), and reads each school under that school's copy's grant. That is what separates them from ?entities=, which asks tenants.children.view_members and reads any school beneath with either grant: the tree is the complex and the schools it manages, nothing else.

Call Answers Scope besides entity.hierarchy.read
ListOperableChildEntities(string $appPermission): OperableEntitiesDto The probe: refusal (null, or the word) and entities — the complex first, then its managed schools by name. Each OperableEntityDto has id (ULID), key, name, type, isRoot, scopes (that copy's own grant) —
ListOperableStudents(string $appPermission, int $page = 1, int $perPage = 500, ?string $entityUlid = null, ?string $gradeUlid = null, ?string $sectionUlid = null, ?string $search = null): PaginatedResult The complex's own pupils and every operable school's, in one list, ordered by school, then pupil; each StudentDto carries entityId and entityName; skipped_entities sits in extra education.students.read
GetOperableStudent(string $appPermission, string $studentUlid): StudentDto One pupil, with their school in entityId — the server's answer education.students.read
GetOperableStudentPhoto(string $appPermission, string $studentUlid): ?string The photograph's path on the public disk, or null — serve it through a route of your own education.student_photos.read
ListOperableStudentFingerprints(string $appPermission, int $page, int $perPage = 1000): array The card roster: students rows of {student_id, full_name, grade_id, section_id, entity_id, entity_name, fingerprints}, and meta with skipped_entities tool_keys.links.read

The scope in the last column is asked twice: of the complex's copy (else MissingScopeException) and of each school's copy. A managed school whose copy lacks it is named in skipped_entities ({id, name, missing_scope}) — never listed as a school with nobody in it — and, for a single pupil, answered as not found.

  • The probe answers with a value. ListOperableChildEntities never throws a refusal: refusal is missing_scope, not_the_app_root or one of the act words, with an empty list — ask it before you draw the complex's screen. A complex whose copy manages no school answers [the complex] and no refusal. Only a permission that is not yours is thrown. ActInChildEntity::refusal($appPermission) answers the same question about the person without listing anything.
  • The other four throw. A scope missing from the complex's copy — entity.hierarchy.read or the read's own — is MissingScopeException. Every other refusal is App\Exceptions\ChildEntitiesNotOperableException with a reason — not_the_app_root, already_acting, not_container, the actor words (no_actor, narrowed, missing_permission, device_elsewhere), and tree_too_large on the two rosters — rendered to a JSON or API caller as 403 {"error": "children_not_operable", "reason": "…"}.
  • The ceiling refuses; it never trims. ListOperableStudents and the card roster refuse a tree of more than 10,000 live pupils or 50 schools as tree_too_large, on every call, whatever it filters: a list cut short turns the pupils past the cut into strangers at the door.
  • One 404 for everything outside. GetOperableStudent and GetOperableStudentPhoto throw the same ModelNotFoundException for a pupil of another customer, a released pupil, a pupil of a school the complex does not operate, and a ULID that names nobody.
  • Fingerprints, never card codes. The roster has no codes key, and every fingerprint is under the complex's secret — the one the Okta app uses on a scan when it is open at the complex. So the gate's scans match these rows, and not a school's own roster.
  • The identity number and the photograph appear on a row only when the complex's copy and that pupil's school's copy both hold the scope; the identity part of a search runs only when every school searched granted it.
  • ListOperableStudents lists released pupils, as a school's own listing does; the card roster leaves them out.
  • key (on OperableEntityDto, and on ManagingEntityDto below) is the platform's number for the entity — the value GetActiveEntityId() answers inside that school — handed to your code for your own tables. toArray() leaves it out; never send it anywhere.

Who manages this copy — GetManagingEntity

__invoke(): ?ManagingEntityDto (scope entity.hierarchy.read): the container whose copy manages the copy you are standing in — id (ULID), key, name, and clock, a TenantClockDto whose source says which link of the chain answered (platform, tenant, country or default). Null for a copy nobody manages, which is the everyday answer. It is read from the live lock, so a complex that uninstalled its copy, or a school moved away, answers null at once. Inside an act the copy standing there is the school's, so it answers with the complex doing the writing — the place a school's settings fall back to.

The viewer inside the act

GetCurrentViewer inside your closure returns the complex's viewer — the complex's entityId, its roles and groups, and your permissions held there, because that is where the authority was asked — with actingInEntityId set to the school's ULID and isActingBeneath() true. Stamp the record with the school, never with entityId: inside the act, entityId is the complex. Outside an act nothing changes (actingInEntityId is null), and a device is never a viewer (null).

The school comes from the pupil, on the server

No header, no body field and no query parameter says which school a record lands in. A school_id sent by the client is a claim, even when it looks right. For a batch, ActForStudentsInTree places the pupils; for one pupil, GetOperableStudent($appPermission, $studentUlid)->entityId answers. A filter such as entityUlid on ListOperableStudents narrows inside the tree the authority already decided — a ULID outside it is an empty page, never a school opened.

Notifications land in the school

  • Inside an act, DispatchNotification sends under the school's copy: the guardians are the pupil's guardians in that school, and the delivery row is the school's.
  • A school's managed copy follows the complex's setting for a notification until somebody at the school configures that row: the complex's switch, channels, quiet hours and wording. The recipients always stay the school's own, and a provider template id carries over only when both entities send that channel through the same provider app. Once somebody at the school edits the row — and a row that was already switched on, reworded or changed before counts as edited — the school's own row governs, a deliberate off included, until they press «Follow ». Nothing in your code changes.
  • Dispatched from the complex's own context to the parents of a pupil of a school beneath it (parent_of_student), a notification reaches nobody: one delivery row, channel all, status failed, error recipient_in_child_entity:<school ULID> — …, and nothing is sent. It cannot be resent — a resend would run in the same place. Send it from inside the school, through ActInChildEntity or ActForStudentsInTree.

What is not here

  • Paired panels. The rule for a device is already in the platform — a panel paired to the complex acts through its role's grants, the same two keys; paired elsewhere it is device_elsewhere; a screen pinned to places is narrowed — but no platform surface opens your app for an operator panel yet. Today the one acting is a person.
  • RunAsChildEntity is not this. It asks nothing about the person: a school that installed your app is entered under its own grant — writes included — whoever is asking. Beyond the tree and grant checks it refuses only a narrowed door (narrowed) and a call from inside an act (already_acting) — «Acting inside a child» above. It is for scheduled tasks, queues and servers with nobody asking. On a request path, read with ?entities= or the tree reads above, and write with ActInChildEntity.
  • The launch marker is not yours to read. A native launch at a complex carries "operates": {"scope": "tree"} when the person, through the role they opened with, may operate the complex's schools for your app and its copy manages at least one; a school's launch — and any launch that does not qualify — has no operates key at all. Only the Okta app reads it: it is not in Okta.context() and never reaches your server. What you see of it is package:okta_face loading the complex's operated tree instead of one school (chapter «native mode», section «Who the recognition camera saw»).
  • Nothing to declare. No new manifest key, no new scope, no minContract. What makes it work is what this chapter already has: hierarchy.managed_install or hierarchy.reach (so there are managed copies), entity.hierarchy.read, and your permission in rbac_permissions.

A complex's departments — GET /entity/departments

A complex may split its schools into departments (optional): each has a manager, staff and its own schools, and a school sits in one department at most. A department's staff see their department's schools alone, on the platform and in your app alike.

In short: nothing to declare and no new scope. The platform fences the tree reads and acts to the person's department by itself, and GET /api/apps/entity/departments (behind entity.hierarchy.read) tells you the departments and where the person stands, so your app can draw schools under their department and explain a missing school instead of looking broken.

What the platform fences for you

For a person in a department of the entity your app is installed on, the schools outside it do not exist on every tree path:

Path Outside the department
/entity/children (and ?descendants=1) not listed
/entity/children/{id} and its logo 404 entity_not_found
?entity=<ulid> / ?entities=… entity_not_found for that school
ActInChildEntity child_entity_not_found
The operated tree reads (ListOperableChildEntities and its siblings) not listed

The whole tree is seen by anyone in no department, and by whoever holds tenants.children.manage_departments (the one who assigns schools to departments). A complex administrator who also manages a department chooses at sign-in between the «complex» card and the «manager of department X» card; the second fences them to that department. Your server on an installation token is never fenced: there is no person to ask about.

So never cache the school list from one person to show another: two staff of the same entity can get different lists.

The answer

{
  "data": [
    {
      "id": "01JB…",
      "name": "Boys' department",
      "description": null,
      "manager": { "name": "Khalid" },
      "staff": 4,
      "schools": ["01JA…", "01JC…"]
    }
  ],
  "viewer": {
    "manages": ["01JB…"],
    "member_of": [],
    "acting_department": "01JB…",
    "reach": ["01JA…", "01JC…"]
  }
}
  • schools are the schools' ULIDs as /entity/children gives them, so join on the id.
  • viewer is null when no person is behind the request (a server).
  • viewer.reach is null when the person sees the whole tree, else what they reach — and it can be empty: a department with no schools leaves its staff on nothing, not on everything.
  • viewer.acting_department is the department a complex administrator entered as, or null.
  • An entity with no departments answers data: [], and everything works as before.
use App\Services\PartnerApi\Entity\Hierarchy\ListDepartments;

$answer = app(ListDepartments::class)();   // embedded apps: the same shape

A mini app reads it with Okta.get('/api/apps/entity/departments').

Supporting departments in your app

  1. Read the schools from the platform every session, not from a stored copy: /entity/children gives exactly what this person reaches.
  2. Group the schools under their departments on the head-office screen: read /entity/departments, and show «no department» for schools none holds.
  3. Say why the list is short: viewer.reach not null ⇒ «showing your department's schools»; empty ⇒ «no schools in your department yet, ask the complex administrator» — never a silent empty screen.
  4. Do not build your own fence: do not read the departments table or compare schools yourself before reading or writing. The platform refuses what is outside the department, and an app that fences on its own drifts from it the day a department changes.
  5. Reports and totals across the tree take their schools from the same /entity/children, so a department's staff never sum schools they cannot see.

View the whole guide on one page · ↧ Raw Markdown