factory/auth (0.6.0)

Published 2026-08-16 13:42:59 +00:00 by bmiadmin

Installation

{
	"repositories": [{
			"type": "composer",
			"url": ""
		}
	]
}
composer require factory/auth:0.6.0

About this package

SaaS Factory auth: platform users, per-project memberships and roles, ability-scoped Sanctum tokens whose tenant is the token's own, and the fail-closed middleware ladder.

factory/auth — v0.1.0

Who is asking, which project they are asking about, and whether they may.

Before this package the tenant came from a client-supplied X-Product-Slug header — the server took the caller's word for which project's rows to open. After it, the tenant comes from the token, and the header becomes an assertion the server checks rather than a fact the server accepts. That sentence is the whole design; everything below is how it is held.

The vocabulary

A "project" is a product_slug. There is no projects table and there will not be one: the kernel's product registry already names every product, and a second list of projects is a second list to keep in sync. The tenant key is the slug.

Four roles, and four is deliberate (Role):

role reaches ability ceiling
platform_admin every project platform., project., inbox., orders., wallet.read, money.withdraw
project_admin one project project., inbox., orders.*, wallet.read, money.withdraw
staff one project inbox., orders.
customer one project inbox., orders., wallet.read

courier, driver, rider, guard are not roles. They are operational labels that decide which shift channel a notification takes, they already live in notify.transports.socket.allowed_roles, and to the API a courier and a guard may do exactly the same things. Adding them here forks one vocabulary into two that must be kept in sync forever — and the sync is what eventually fails open.

Ten abilities (Ability), a closed enum: platform.read/write, project.read/write, inbox.read/write, orders.read/write, wallet.read, money.withdraw. Closed because Sanctum accepts any string as an ability, so inbox.raed mints a token that authenticates fine and then silently fails every gate it touches. Ability::parse() refuses the typo at issuance, where a human is still watching.

There is no wildcard. Sanctum's ['*'] turns every ability the platform has not written yet into one this token already has. Tokens::issue() refuses it, AccessToken::tokenCan() overrides Sanctum's short-circuit so a * written by hand or by an older migration is still not special, and a doctrine test asserts the literal appears nowhere as a grantable value.

Reading a balance and moving money are separate rights, and no consumer role may ever withdraw (D26: a consumer tops up to spend, so there is nothing to withdraw). That is a test, not a policy note.

The __platform__ sentinel

The founder holds a membership of a pseudo-tenant called __platform__ rather than a row with a nullable tenant column. A nullable tenant means every query that filters by tenant needs a special case, and the special case is where the fleet-wide read leaks.

The constant has exactly one source — Membership::PLATFORM = Actor::PLATFORM = AuditLog::PLATFORM_CHAIN, a constant aliasing a constant — so the auth package and the audit chain cannot drift apart on what "the platform" is. A doctrine test asserts all three are identical.

platform_admin and __platform__ go together or not at all (an XOR checked in Memberships::grant()): a platform_admin scoped to one project would hold a ceiling including platform.* over a single tenant, and a non-platform role on __platform__ would be authority over a pseudo-tenant nobody's rows belong to.

The ladder

Four middleware, each of which fails closed on its own:

Route::middleware(['factory.auth', 'factory.scope'])->group(function () {
    Route::get('/inbox', ...)->middleware('factory.can:inbox.read');
    Route::post('/withdraw', ...)->middleware('factory.can:money.withdraw');
    Route::get('/cockpit', ...)->middleware('factory.role:platform_admin');
});
  1. factory.auth — resolves the bearer through Sanctum::personalAccessTokenModel()::findToken(), then re-reads the membership and the user on every request. This is the revocation path: demote a role and the tokens already in the wild weaken on their next use; suspend a membership and they die. What the token said at issuance is never trusted a second time.
  2. factory.scope — binds CurrentProduct from the token, not the header. A project token that sends a rival slug gets 403; repeating its own slug is fine. A platform token may name any registered slug to drill down (an unknown one is 400), and with no header binds no product at all — which is what the fleet-wide cockpit reads.
  3. factory.can:a,b — requires ALL listed abilities.
  4. factory.role:x,y — requires ANY listed role.

Every rung above the first reads CurrentActor::getOrNull() and returns 401 when nothing is bound, so a route that forgets factory.auth refuses rather than running as nobody. That is also a doctrine test — the day somebody forgets is the day the route is worth taking.

No guard, no cookie, no CSRF surface. This package defines no auth guard and never reads config('auth.*'); installing it is registering a provider. A guard would drag in Sanctum's stateful SPA path, and this platform only ever speaks bearer tokens to a Flutter client.

CurrentActor lives in the kernel

The dependency graph is kernel ← infra, kernel ← auth; infra never learns that an auth package exists. CurrentActor and Actor are kernel primitives holding only strings, so infra's UserRef can read the authenticated identity without inverting the graph.

Actor::label() is "{$type}:{$id}" → factory_user:1, and AuditLog::record() resolves its actor as $actor ?? CurrentActor::getOrNull()?->label() ?? 'api'. Passing an explicit actor string is therefore how an audit row gets worse — it overrides the real one. Don't, unless there genuinely is no human.

Issuance is one chokepoint

Tokens::issue() is the only place in the package that calls createToken(), asserted by a doctrine test. Everything that makes a token trustworthy — tenant binding, the role ceiling intersection, the wildcard refusal, the issuance audit — lives there, so a second minting path would turn all four from rules into advice.

Abilities are intersected with the role's ceiling at issuance, and the role is re-read at authentication, so a token can never out-rank the person holding it — not at issuance, and not after a demotion.

Lifetimes are a gradient (config/factory_auth.php): platform_admin 7 days, project_admin and staff 30, customer never. The most dangerous token expires fastest; a customer's phone token expiring monthly would be a support ticket, and it can do nothing outside one project anyway. Expiry is a floor, not the revocation story — the per-request membership re-check is.

Data

  • factory_users — ref (u_ + ULID) is generated at creation and never derived from the email or name: a ref built from an attribute changes when the attribute is corrected, and a partition key that changes is a wallet that changes hands. A user needs an email or a phone — one with neither can never be reached again, so account recovery becomes "whoever asks first".
  • factory_memberships — unique on (user_id, product_slug). One person can hold different roles in different projects. Suspending one membership leaves the other project's tokens alone; suspending the user takes every project.
  • personal_access_tokens — Sanctum's own table, plus a product_slug column added by an additive migration.

Every table is factory_-prefixed so the package installs beside Laravel's own scaffolding, Breeze, Jetstream, or the prototype already on prod. Membership deliberately does not use BelongsToProduct — it is what resolves the product in the first place, so scoping it by the current product would be circular and would fail closed to an empty set on the one query that must run before any product is bound.

Console

# Bootstrap: a platform with no users cannot mint the first token, and the first
# token is a platform_admin one. A seeded default admin with a known password is
# the dishonest version of this.
php artisan factory:user "Ada" --email=ada@example.test --role=platform_admin

php artisan factory:token ada@example.test --product=__platform__ --name=cockpit
php artisan factory:token ada@example.test --list
php artisan factory:token ada@example.test --revoke=7

The plaintext is printed once, at issuance, and never again. A command that could re-show a token would be a command that could read one out of the database.

Audit key naming (a real trap)

AuditLog::isSecretKey() matches by substring, against password, passwd, secret, token, authorization, credential, api_key, apikey, private_key, pin, cvv, card_number. So an audit key named tokens_revoked is written as [redacted] — a plain integer, destroyed by the redactor.

This bit twice during development (access_token_id, then tokens_revoked in two files), so DoctrineTest::no_audit_key_this_package_writes_is_scrubbed_by_its_own_rule greps every changes key literal out of the source and fails on any that would be redacted. Prefer revoked_id, revoked_count.

Consumers

  • factory/jurisdiction ^0.2 — its routing-table editor is gated by factory.role:platform_admin; it retired its own X-Super-Admin-Token shared secret to consume this instead.
  • factory/infra — UserRef::from() returns the bound actor's ref and ignores the client header entirely when an actor is present.

Tests

composer test — 68 tests, 644 assertions. TokenTest (issuance, ceilings, revocation), LadderTest (21 HTTP tests over the four middleware, including cross-tenant refusal and live demotion), MembershipTest, and DoctrineTest — the last of which tests the shape rather than the behaviour, and has already caught two live defects that behaviour tests passed straight over.

Not shipped, deliberately

No login route. This package mints tokens server-side; it does not yet authenticate a password over HTTP. The Flutter fleet therefore holds no tokens, which is why the nine shape APIs are st

Dependencies

Dependencies

ID Version
factory/infra ^0.1 || ^0.2 || ^0.3
factory/kernel ^0.3 || ^0.4 || ^0.5 || ^0.6 || ^0.7 || ^0.8 || ^0.9 || ^0.10
illuminate/console ^10.0 || ^11.0 || ^12.0 || ^13.0
illuminate/database ^10.0 || ^11.0 || ^12.0 || ^13.0
illuminate/hashing ^10.0 || ^11.0 || ^12.0 || ^13.0
illuminate/http ^10.0 || ^11.0 || ^12.0 || ^13.0
illuminate/routing ^10.0 || ^11.0 || ^12.0 || ^13.0
illuminate/support ^10.0 || ^11.0 || ^12.0 || ^13.0
laravel/sanctum ^3.3 || ^4.0
php ^8.2

Development dependencies

ID Version
orchestra/testbench ^8.0 || ^9.0 || ^10.0 || ^11.0
phpunit/phpunit ^10.1 || ^11.0 || ^12.0
Details
Composer
2026-08-16 13:42:59 +00:00
32
proprietary
128 KiB
Assets (1)
Versions (3) View all
0.6.0 2026-08-16
0.4.0 2026-08-14
0.3.0 2026-08-06