Getting started

Scopes

Every piece of data in Okta is gated by a scope of the form:

<feature>.<resource>.<action>

Examples:

education.students.read       → list/show students
education.students.write      → add/update students (no delete)
education.face_embeddings.read → face templates (dangerous, read-only)
employees.directory.read      → list/show employees
reports.builder.read          → run curated reports
reports.builder.write         → propose letterheads (not reports)

Rules:

  1. lowercase + dot-separated with no wildcards.
  2. Partners only ever get read or write. Delete is never granted to partners.
  3. The catalog is the single source of truth. You can't invent new scopes.
  4. Okta adds new scopes through platform updates.

Pull the live catalog:

GET /api/partners/permissions/catalog

For the full per-resource scope reference (endpoints + PHP/HTTP examples), see commit a164484 on prod — the content is unchanged.

education.face_embeddings.read — face templates

A dangerous scope. The install screen paints it in the danger colours and the tenant admin will stop and read it. Request it only if your app genuinely matches faces, and write a reason a head teacher can understand.

GET /api/apps/education/face-embeddings?model=AdaFace1&page=1&per_page=50

Returns the tenant's enrolled face templates as raw numeric vectors, so your app can match them on a machine in the building.

There is no .write tier and there never will be. Enrolment happens in the platform with the person standing at a desk; an app able to write a template could make any camera recognise anybody as anybody.

Why it is separate from education.student_photos.read. They look like the same consent and are not. A photograph is what the school already prints on a card and shows at a gate. A template is a key that matches this child against any camera anywhere — and unlike a password it cannot be reissued after a leak. A tenant that agreed to show faces has said nothing about handing over the mathematics that recognises them.

Four things to know before you integrate:

  • model is required and has no default. AdaFace1 and ArcFace_50r1 produce vectors that are not comparable with each other. If the platform picked one for you, you would receive templates your engine silently fails to match — with no error anywhere.
  • count is not total. count is what this page carries after released students and undecryptable rows drop out; total is what the tenant holds. Page on total — deriving it from count stops your loop early on the first page that happens to contain a released student.
  • No names come back. You get vectors keyed by ULID. If you need to know whose face you matched, hold education.students.read as well and resolve the ULID there. One grant does not quietly deliver two.
  • Matching happens on your side, not the platform's. The vectors are handed over raw because the comparison has to happen where the camera is — a gate, in the building, usually offline. An endpoint returning similarity scores would require the network the gate does not have.

From a mini-app this needs no special symbol in okta_host — the host allow-list already passes /api/apps/*:

final res = await Okta.get('/api/apps/education/face-embeddings?model=AdaFace1');

The organisation's clock — /api/apps/tenant/clock

No scope. Every installed app reaches it with its installation token alone, and nothing is asked of the organisation.

That is a decision rather than an omission: a timezone is not the organisation's data, it is the frame its data is read in — like the locale. Your app already receives that school's timestamps, so there is nothing here a grant could protect. Putting a scope in front of it would buy zero privacy and cost something real: apps rendering times in the wrong zone because nobody thought to request a grant nobody thought to give. A register shown an hour out does not look broken — which is what makes that failure expensive.

Method Path Scope Description
GET /api/apps/tenant/clock — The wall clock the organisation reads its day by

Response:

{
  "timezone": "Asia/Riyadh",
  "offset": "+03:00",
  "now": "2026-09-02T14:31:07+03:00"
}

Store timezone, never offset

offset and now describe this instant, not this place. Only the name carries the rules, so it stays right when the rules change under it.

This is not hypothetical — one supported country observes daylight saving:

Zone January July
Asia/Riyadh +03:00 +03:00
Asia/Dubai +04:00 +04:00
Africa/Cairo +02:00 +03:00

An app that stored +02:00 for a Cairo school in January is a full hour wrong in April — on every screen, with no error in any log, the date right, the format right, and only the hour false. Had it stored "Africa/Cairo" it would have moved with the country on its own.

Use offset to show beside a time, or to hand to something that cannot take a zone name. Nothing else.

Storage stays UTC

Every timestamp the platform hands you, and every one you hand back, is UTC — and that does not change. This endpoint answers a different question: what to display. Convert on the way out, never on the way in; a value converted at write time is one nobody can compare across two schools afterwards, and the damage is invisible until two schools appear in the same report.

Where the value comes from

The school picks it in its own settings. If it has not, the value is derived from its country when that country has exactly one zone, and otherwise from the platform default. You always receive the final answer — so do not treat "no explicit choice" as a special case.

Embedded (PHP):

use App\Services\PartnerApi\Tenants\GetTenantClock;

$clock = app(GetTenantClock::class)();

$clock->timezone;   // "Asia/Riyadh" — this is what you store
$clock->offset;     // "+03:00" — display only
$clock->now;        // ISO8601 on the school's clock

// And a stored UTC moment read on their clock:
app(GetTenantClock::class)->at($row->created_at)?->format('Y-m-d H:i');

From a mini-app (Dart) — needs no new symbol in okta_host and no minContract bump:

final res = await Okta.get('/api/apps/tenant/clock');
if (res.status == 200) {
  final zone = res.body['timezone'];   // store this
}

Common uses: printing attendance times as the person who marked them saw them, deciding what "today" means for the school, scheduling a reminder for the local morning rather than the server's.

Do not ask once and store now. "What time is it" is a question you ask when you need the answer; the name is the only part worth keeping.


Identifiers and core_reference

Every response leaving the platform replaces numeric IDs with ULIDs. A core_reference is a column in your table holding one of them: a 26-character string pointing at a row in a platform table on okta-web (students, subjects, grades, sections, tenants, tenant_employees, users).

Why there is no real FK constraint: the referenced row lives in a different database, on okta-web, and Postgres has no cross-database foreign keys. So what you store is a plain char(26) / varchar(26) with no constraint at all — and that is exactly the problem: char(26) is a width you may legitimately use for anything else (a coupon code, an external invoice number, a hash prefix). The width alone is not evidence, and treating every char(26) as a platform reference would reject your legitimate writes.

So the guard does not guess: you must annotate the column explicitly in the migration.

The annotation — two forms

Form A — a column comment in the Postgres catalog (preferred), because it survives into the live database where okta-web and the diff tooling can read it back:

CREATE TABLE "students" (
    "id"           BIGSERIAL PRIMARY KEY,
    "owner_ulid"   CHAR(26) NOT NULL,
    "teacher_ulid" CHAR(26)
);

COMMENT ON COLUMN "students"."owner_ulid"   IS 'core_reference:tenants';
COMMENT ON COLUMN "students"."teacher_ulid" IS 'core_reference:users';

In a Laravel migration that is ->comment():

$table->string('owner_ulid', 26)->index()->comment('core_reference:tenants');

Form B — a SQL comment on the column's own line, inside CREATE TABLE or on a single-line ALTER TABLE … ADD COLUMN:

CREATE TABLE "students" (
    "id"         BIGSERIAL PRIMARY KEY,
    "owner_ulid" CHAR(26) NOT NULL, -- core_reference: tenants
);

ALTER TABLE "students" ADD COLUMN "teacher_ulid" CHAR(26); -- core_reference: users

The separator is flexible: core_reference: tenants, core_reference=tenants and core_reference(tenants) are all accepted. A bare core_reference with no target still counts as an annotation (the ULID-format rule applies whichever platform table is referenced) — but always name the target, since that is what lands in the manifest.

What happens without an annotation

Nothing is rejected — that is the dangerous part. The column simply becomes invisible to the guard, so it is not protected:

  • App\Services\Partners\Sandbox\ValidateCoreReferenceWrites does not know the column is a platform reference, so any value you write into it passes without a ULID-format check and without checking the target table is allowed.
  • The manifest's core_references block comes out empty for your unannotated columns.

Unannotated 26-wide columns are reported separately as candidates and shown in the portal — a heads-up, not a rejection. The difference between "this table has no platform references" and "it may have ones we cannot see" is yours to settle, by annotating.

Path-based migrations (a .php file living in your repo) carry no SQL that can be inspected at check time; they are counted as unreadable and reported as unchecked. Do not assume full coverage with them.

Forbidden (annotated or not):

  • (int) $row->student_id_ulid — casting it to an integer.
  • where('student_id_ulid', 12345) — comparing it to a numeric literal.

Both are caught by the boilerplate scanner and fail CI. Always store and pass it as a string.

View the whole guide on one page · ↧ Raw Markdown