factory/auth (0.6.0)
Installation
{
"repositories": [{
"type": "composer",
"url": " "
}
]
}composer require factory/auth:0.6.0About this package
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');
});
factory.auth— resolves the bearer throughSanctum::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.factory.scope— bindsCurrentProductfrom 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.factory.can:a,b— requires ALL listed abilities.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 aproduct_slugcolumn 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 byfactory.role:platform_admin; it retired its ownX-Super-Admin-Tokenshared secret to consume this instead.factory/infra—UserRef::from()returns the bound actor'srefand 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 |