Keine Beschreibung

clanker 1ec844ecb1 feat!: replace SSR engine with Statum extensions - auth/user-management library, ready-made actions, spry CLI with templates and Docker support vor 1 Woche
src 1ec844ecb1 feat!: replace SSR engine with Statum extensions - auth/user-management library, ready-made actions, spry CLI with templates and Docker support vor 1 Woche
tools 1ec844ecb1 feat!: replace SSR engine with Statum extensions - auth/user-management library, ready-made actions, spry CLI with templates and Docker support vor 1 Woche
vapi 13c9f429a0 feat: add expression evaluation and declarative components to template system vor 5 Monaten
.gitignore 8a95cec455 refactor(core): rename spry-template-outlet to spry-content vor 5 Monaten
README.md 1ec844ecb1 feat!: replace SSR engine with Statum extensions - auth/user-management library, ready-made actions, spry CLI with templates and Docker support vor 1 Woche
meson.build 1ec844ecb1 feat!: replace SSR engine with Statum extensions - auth/user-management library, ready-made actions, spry CLI with templates and Docker support vor 1 Woche

README.md

Spry

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).

The stack

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

Module usage

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).

The auth slot

Identity 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.

Resolving identity and guarding handlers

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.
  • Guards are authoritative: they take the caller's 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.
  • Guards take the caller's 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.

Shipped actions (Spry.Actions)

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.

Sealed row actions (user management)

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.

The user store (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.

Migrating from old Spry

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