|
|
hace 1 semana | |
|---|---|---|
| src | hace 1 semana | |
| tools | hace 1 semana | |
| vapi | hace 5 meses | |
| .gitignore | hace 5 meses | |
| README.md | hace 1 semana | |
| meson.build | hace 1 semana |
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.
| 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 |