|
|
1 hafta önce | |
|---|---|---|
| src | 1 hafta önce | |
| tools | 1 hafta önce | |
| usm-scripts | 1 hafta önce | |
| vapi | 5 ay önce | |
| .gitignore | 5 ay önce | |
| .usmignore | 1 hafta önce | |
| MANIFEST.usm | 1 hafta önce | |
| README.md | 1 hafta önce | |
| meson.build | 1 hafta önce |
Spry is a library of extensions to Statum: authentication and
user-management services plus ready-made, subclassable Statum actions. Spry no
longer renders anything — pages, layouts and static resources are Statum's
job, and auth UI arrives as app-owned templates generated by the spry CLI
(spry add login, spry add register, spry add user-management).
astralis HTTP request handling
└─ Statum pages, slots/state, directives, client JS
└─ Spry THIS LIBRARY: users, auth slot, actions, guards
├─ Invercargill-Sql spry_users / spry_user_permissions schema
└─ Inversion module + service wiring
var application = new Astralis.Application(...);
application.add_module<StatumModule>();
application.add_module<SpryModule>(); // UserService + schema + migrations
var statum = application.configure_with<StatumConfigurator>();
SpryAuth.register_actions(statum); // or hand-pick with statum.action<T>()
SpryModule registers no endpoints. It wires the M0001 migration
(spry_users, spry_user_permissions), the UserEntity/UserPermissionEntity
mappings, the UserProjection projection and the scoped UserService.
Seed an admin at startup (idempotent — safe on every boot):
var scope = application.container.create_scope();
yield SpryAuth.ensure_admin(scope.resolve<UserService>(),
"admin", "admin@example.com", "change-me");
ensure_admin looks the account up by username (not a paged listing), so
it stays correct past any user count, and grants admin only when absent
(set_user_permission is itself idempotent).
auth slotIdentity is a signed Statum SESSION slot of type name "auth" — no cookies, no
bearer tokens. It is written once at login
(SpryAuth.login) and read on every request
(SpryAuth.current).
| Field | Location | Protection | Notes |
|---|---|---|---|
username |
public | signed (tamper-proof, browser-visible) | login name; bind for nav/guards |
display_name |
public | signed | forename + surname, falling back to username |
permissions |
public | signed | string[] of permission patterns; admin/* match everything, prefix* wildcards |
logout |
public | signed | embedded ActionDto reference to LogoutAction for logout buttons |
user_id |
private | signed + encrypted (never reaches the browser) | authoritative spry_users.id |
Because user_id lives in the slot's encrypted private data, a stolen browser
copy of the public data cannot impersonate another user, and because the whole
slot is signed (static keys in web-config.json), sessions survive a server
restart.
public class ProfileEntrypoint : StatumEntrypoint {
protected UserService users = inject<UserService>();
public override async DirectiveBuilder handle() throws GLib.Error {
var guard = yield SpryAuth.require_login(directives(), request, users);
if (guard != null) {
return (!)guard; // navigate("/login")
}
var me = SpryAuth.current(request);
if (!me.has_permission("reports.read")) { // admin / users.* wildcards work
...
}
...
}
}
SpryAuth.current(request) → CurrentUser (user_id, username,
display_name, permissions, is_anonymous, has_permission(string)).
Anonymous when no auth slot is held. This is the cheap display
identity: it reflects the signed slot snapshot minted at login, so a
disabled/revoked account still resolves until its slot is cleared.SpryAuth.require_login(directives, request, users, redirect = "/login")
→ null when the held slot's user id still maps to a live, enabled
account, else a navigate(redirect) builder. Async — yield it.SpryAuth.require_permission(directives, request, users, permission, redirect = "/login")
→ same shape, with the permission set re-derived from the store.UserService and
re-validate on every guarded request, so disabling, deleting or
revoking takes effect immediately (no stale login-time snapshot). A
store failure fails closed (treated as logged out) after logging a
warning.DirectiveBuilder (from the framework-provided
directives()) because DirectiveBuilder construction is internal to
Statum; the builder-in/builder-out shape keeps chaining fluent.has_permission (and the guards' matcher SpryAuth.permission_matches)
preserve the old PermissionMatcher semantics exactly: admin and *
are super-user patterns, prefix* matches any permission with that
prefix, everything else is an exact match.
All are StatumAction subclasses designed for subclassing: redirect URIs and
every notification message are protected virtual properties, and handle()
stays a thin delegate to SpryAuth/UserService.
| Action | Form fields | Guard |
|---|---|---|
LoginAction |
username, password |
— |
RegisterAction |
username, email, forename, surname, date_of_birth, password, confirm_password |
— |
LogoutAction |
— | — |
ChangePasswordAction |
current_password, new_password, confirm_password |
logged-in |
SetUserEnabledAction |
enabled; target from sealed private data or user_id |
require_permission (default admin) |
GrantPermissionAction |
permission; target from sealed private data or user_id |
require_permission (default admin) |
RevokePermissionAction |
permission; target from sealed private data or user_id |
require_permission (default admin); removes only the matching rows |
AlterUserAction |
username, email, forename, surname, date_of_birth, enabled, new_password (optional); target from sealed private data or user_id |
require_permission (default admin); refuses self-alter |
DeleteUserAction |
— (sealed private data or user_id) |
require_permission (default admin); refuses self-delete |
Register them all with SpryAuth.register_actions(statum), or individually
with statum.action<T>() (e.g. your own LoginAction subclass with a
different landing_uri).
Register and Alter share one validator (Spry.Actions.ProfileValidation):
username length, email shape, required names and a strict YYYY-MM-DD date
of birth parse that rejects impossible calendar dates (31 February and
friends) by round-tripping the parsed date. Both actions map uniqueness
failures to the same friendly duplicate messages, so raw database errors
never reach the client.
The five management actions read their target user_id from sealed
private data: a row action reference authored with
var target = new Spry.Actions.UserActionPrivate();
target.user_id = row_user.id;
row.grant = action_registry.author_private<Spry.Actions.GrantPermissionAction, Spry.Actions.UserActionPrivate>(target);
carries the id in an encrypted X-Statum-Private blob sealed under the
action's own namespace, which the shipped action decrypts directly
(Spry.Actions.UserActionPrivate is the shared private model — no app-side
subclasses needed). The plain user_id form field still works when a
request carries no blob, so hand-written forms keep functioning.
ChangePasswordAction verifies its current_password form field against
the stored hash (UserService.authenticate_user) before writing the new
password.
UserService)Injected per-request (inject<UserService>()); every method is async
and throws Error:
| Method | Behaviour |
|---|---|
authenticate_user(username, password) |
UserProjection? — null for unknown username, wrong password or disabled account (same generic failure, no enumeration side channel; unknown usernames burn a dummy hash check so both paths take comparable time) |
get_user(user_id) / get_user_by_username(username) |
UserProjection? with the user's permission patterns |
register_user(...) |
inserts and returns the new UserEntity |
set_password, alter_user, set_user_enabled, delete_user |
throw UserServiceError.USER_NOT_FOUND for a stale id instead of crashing |
set_user_permission(user_id, permission) |
idempotent — no duplicate row when already held |
remove_user_permission(user_id, permission) |
single targeted delete of the matching rows (other permissions untouched) |
clear_user_permissions, get_user_permissions, list_users |
bulk read/clear, unchanged |
Guards (SpryAuth.require_login/require_permission) build on
get_user for their per-request re-validation.
spry CLIspry new <name> scaffolds an application — including its USM packaging
(MANIFEST.usm + usm-scripts/ + .usmignore); spry add
page|action|resource grows it inside marker blocks; spry add
login|register|user-management adds auth UI bound to Spry.Actions;
spry keys maintains the static key pairs; spry deploy builds the USM
container image. spry dev is the development loop: build, run, watch —
saving a file rebuilds and restarts the app (a failed build keeps the
previous process running), and Statum's client-carried state makes
restarts transparent to the browser. Run spry --help for the full
command surface.
spry deploy delegates to usm manifest deploy in the application
directory: the application's MANIFEST.usm/usm-scripts/ drive an
in-container usm install where the system package manager resolves the
platform libraries and toolchain and USM repositories resolve the
Web-Stack, then the image is saved as <app>-<version>.image.tar.xz
(podman load -i to import).
spry deploy [--exec CMD] [--base IMAGE] [--repository FILE]...
[--installer-url URL] [--no-build] [--usm FILE]
--exec CMD (default <app> 8080) — the container entrypoint.--base IMAGE (default usm's fedora:43) — the base image.--repository FILE (repeatable) — resolve from exactly these .usmr
repositories instead of the machine's configured set; a local Web-Stack
repository is the usual choice while the canonical one is unavailable.--installer-url URL — override the canonical USM installer source the
image installs USM from; until that URL is hosted, its file:// form
carries a locally built installer into the image (the sanctioned
local-testing path).--no-build — stop after generating the deploy context.Keys are never packaged (.usmignore excludes web-config.json): run
with ephemeral keys for plain page serving, or bind-mount the config
read-only (ASTRALIS_CONFIG_PATH points the app at it) for full
authentication.
| Old (≤ 0.1) | New (0.2) |
|---|---|
AuthorisationContext (injected) |
SpryAuth.current(request) |
AuthorisationService/AuthorisationToken, cookies |
the signed auth slot; SpryAuth.login(...) |
authenticate_user returning a token |
returns UserProjection? |
LoginComponent/UserManagementComponent UI |
spry add login / spry add user-management CLI templates bound to Spry.Actions |
AuthenticationModule |
SpryModule |
PermissionMatcher |
CurrentUser.has_permission |
CryptographyProvider, UserIdentityProvider, continuations, spry-mkssr/spry-mkconst, htmx resources |
deleted — Statum handles signing/encryption, pages, resources and client JS |