Commit Graph

95 Commits

Author SHA1 Message Date
Jakub Zych
0f545f5c81 feat: user MCP guard provider, plugin frontend permissions and the user_me tool
- classes.FrontendPermissionCatalog: the seeded rows, then codes activated
  plugins declare through pact.HasFrontendPermissions (seeded row wins);
  both permission editors offer it
- classes.MCPUserGuard (guard "user"): reads user.api_token, user.scope:*
  and user.permission:*, allows with the route's checks (session scopes,
  group grants, every code), dispatches with an in-memory token credential,
  resolves usr_ personal tokens; registered in *pact.MCPGuards and published
  as pact.MCPPersonalTokenResolver
- GET /_user/api/v1/token/me (user.api_token, user.scope:read) and the MCP
  tool me (user_me)
- pact.MCPSubjectChanged on token revoke, Users saves and User Groups saves
  and deletes
2026-10-23 12:08:38 +02:00
Jakub Zych
7ac80c9aea feat: verified frontend account link for administrators
- golem15_user_backend_links (1:1, both sides unique) and one-shot hashed
  golem15_user_backend_link_requests (golem15.user.backend_links.request_ttl, 10m)
- classes.BackendLinks: CreateLinkRequest, ConfirmLinkRequest (row lock,
  JWT iat not before the request), SetLink, RemoveLink, LinkedBackendUser;
  published as *classes.BackendLinks and pact.MCPLinkedBackendResolver
- GET /_user/api/v1/backend-link/{code} and POST /backend-link/confirm (jwt.auth)
- My account section frontend_account with link and unlink
- BackendLinkedEvent, BackendUnlinkedEvent and pact.MCPSubjectChanged after commit;
  deleting or deactivating a frontend user removes its link
- golem15.user.frontend_url (falls back to app.url)
2026-10-23 12:03:26 +02:00
Jakub Zych
3828a65481 feat: golem15.user.password.public_routes false leaves POST /login, /forgot-password and /reset-password unregistered, so they 404 like any unknown path
The key defaults to true, so the route table is unchanged. False skips only
those three registrations: they get the router's own 404, with no custom
handler and no framework change. /register keeps allow_registration and the
turnstile guard. The key is read at boot, and a value other than true or
false fails boot like golem15.user.turnstile.
v0.1.9
2026-10-23 00:09:38 +02:00
Jakub Zych
8bdf2c7d1a feat: opt-in golem15.user.jwt.token_cookie sets the HttpOnly session cookie on /login, /register and /activate-by-code instead of returning the token
Default off, so body-token clients are unchanged. When on, the cookie carries the auth.cookie attributes via bouncer.LoadCookieConfig and the body keeps user (and message); SameSite stays Lax, with no CSRF token; personal API tokens are untouched.
2026-10-22 23:44:44 +02:00
Jakub Zych
846087db45 fix: a /refresh carried by the auth.cookie session cookie rewrites that cookie, so an HttpOnly session survives the rotation
8e4b863 answered a cookie-carried refresh in the body only and blacklisted the old jti, so an HttpOnly cookie (golem15.sociallogin token_cookie) kept a dead token. Now the cookie named auth.cookie.name gets the rotated token and the body is {}; bearer and auth_token refreshes are unchanged, and refusals write no cookie.
2026-10-22 23:39:18 +02:00
Jakub Zych
a484d0e9a2 fix: reset-password and the admin password set stamp tokens_valid_after in whole seconds, so a token minted in the same second is accepted
JWT iat is whole seconds and every cutoff check is iat before tokens_valid_after, so a sub-second cutoff refused the login that followed a reset within the same second. POST /reset-password and the admin Users password set now truncate the cutoff to the second, like RevokeUserAccess; email_verified_at keeps full precision. Change-password and bouncer's comparison are unchanged.
2026-10-22 22:56:38 +02:00
Jakub Zych
b6f8055c56 fix: /login throttles failed logins per client address behind trusted proxies (surf.ClientIP), like /register
Behind a reverse proxy every visitor had the proxy's RemoteAddr, so one client's failed logins locked the account for all. The throttle key is now surf.ClientIP with http.trusted_proxies, the expression /register uses; X-Forwarded-For from an untrusted peer is still ignored. RemoteIP is unchanged.
2026-10-22 22:53:29 +02:00
Jakub Zych
4bf44fc6ea fix: logout revokes the session under serve: the jwt blacklist and GORM hooks are wired when the database is published
Boot runs before summer serve publishes the database, so the plugin's GORM
hooks, the Postgres jwt_blacklist store and its sweep now go through
lagoon.OnDatabase, and the jwt guard resolves the published store on every
request. A logged-out token is refused on jwt.auth, /fetch and /refresh.

The jwt guard reads the cookie names from classes.SessionCookieNames().
2026-10-22 22:29:59 +02:00
Jakub Zych
8e4b863a3f fix: /fetch, /refresh, /update and /change-password accept the session cookies the jwt guard reads
The four routes (and the other signed-in routes through authenticate) read the bearer header, then the token and auth_token cookies, through classes.SessionToken and one cookie-aware guard.
A bearer header still wins and no credentials stays 401 with the old bodies.
A cookie-carried /refresh answers the rotated token in the body and writes no cookie.
2026-10-22 21:45:52 +02:00
Jakub Zych
15ff9793ba fix: login refuses a not-activated account while require_activation is on
The refusal is the same 401 invalid-login answer as a wrong password and is counted by the login throttle.
The activation is checked after the password and before the deactivation restore, so a refused account stays deactivated.
require_activation false keeps the previous behaviour.
2026-10-22 21:25:53 +02:00
Jakub Zych
930b1587a0 chore: require SummerCMS v0.4.3
The organisation role cleanup uses cabana's ClearOnChange, released in v0.4.3.
v0.1.8
2026-10-22 10:42:22 +02:00
Jakub Zych
358ee5d308 fix: logout ends cookie-carried sessions, clears the session cookies and keeps the token revoked for its refresh window
POST /logout read the token from the Authorization header only, while the
jwt guard also accepts the token and auth_token cookies. A session carried
by a cookie (golem15.sociallogin's token_cookie sets an httpOnly one that
the browser cannot turn into a bearer header) was answered 401, nothing was
revoked and the cookie kept authenticating jwt.auth routes until the access
token expired (golem15.user.jwt.ttl, 60 minutes). Logout never cleared a
cookie either, and it and EndRequestSession kept the jwt_blacklist row only
until the access token's exp, so once the sweep removed the row POST
/refresh revived the logged-out token for the rest of its refresh window.

classes.EndSession, which Logout now runs, revokes every session token the
request carries (the bearer header, the token and auth_token cookies) that
verifies, an expired one too while it could still be refreshed, until the
later of exp and iat plus refresh_ttl, plus a minute, as bouncer.Refresh
and the admin logout do. The answer, a 401 included, expires the
auth.cookie token cookie and any other session cookie the request carries,
with that cookie's path, domain, SameSite and Secure. EndRequestSession is
its best-effort form. Response bodies are unchanged; an expired but still
refreshable token now logs out with 200 instead of 401.
2026-10-22 04:51:36 +02:00
Jakub Zych
aa8396916e fix: clear a stale organisation_role when a user's organisation changes in the admin
The members relation manager and the organisation field of the Users form
declare cabana ClearOnChange on organisation_role: adding or removing a
member, and a form save that moves a user to another organisation or to
none, clear the role unless the same save writes it. Needs a SummerCMS
release with ClearOnChange.
2026-10-22 03:33:12 +02:00
Jakub Zych
c3712dc0d4 fix: deactivate and ban revoke sessions and API tokens, and /refresh refuses deactivated, banned and cut-off subjects
classes.RevokeUserAccess stamps users.tokens_valid_after (whole seconds) and
revokes the users' personal API tokens; DeactivateUsers and BanUsers call
it for every selected user. Restore and unban revive nothing, and a
deactivated user who signs in again gets a fresh working token.

POST /refresh now loads the token's subject after bouncer.Refresh and
refuses a missing, deactivated or banned user and a token issued before
tokens_valid_after with the existing 401 body; a refused refresh stamps no
last_seen. Suspension is deliberately not checked.
v0.1.7
2026-10-22 03:11:32 +02:00
Jakub Zych
e0b5ffe20d fix: log /register listener failures instead of discarding them
A failing user.Register or user.Created listener is logged at Error level
with the user ID; the committed registration still succeeds.
2026-10-22 03:07:22 +02:00
Jakub Zych
b6b7ac7534 fix: registration fills only an explicit allow-list and never takes the IP columns from the body
User.RegisterFillable lists what a registration body may set: name,
surname, email, password, preferred_locale and marketing_consent.
RegisterUser fills from it instead of Fillable, so organisation_id,
organisation_role, is_onboarded and must_change_password can no longer be
mass-assigned through POST /register, and created_ip_address and
last_ip_address come only from RegisterOptions.IP. Fillable is unchanged
for the admin form and trusted application code.
2026-10-22 03:03:18 +02:00
Jakub Zych
499b6dc408 test: migration rollback counts include the email_verified_at migration
The rollback tests of user groups, extend-users, throttle and jwt blacklist
stopped one migration short since 202610210001 was added.
2026-10-22 03:02:08 +02:00
Jakub Zych
9986342c23 feat: console help, examples and named loggers (SummerCMS v0.4.0)
Every command gets long help and examples, descriptions in imperative
sentence case, and a warn-only TestCommandsDocumented over bonfire.CheckDocs.
The plugin logs through a diary component logger instead of the process
default logger. Requires SummerCMS v0.4.0; command names and flags unchanged.

user:token:create and user:token:list gain --json, user:backfill-handles a
progress bar; planning IDs leave the descriptions.
v0.1.6
2026-10-21 18:03:50 +02:00
Jakub Zych
35cf16689d test: email_verified_at writers, non-writers and the migration
- TestEmailVerifiedAt: register (auto, admin), activate, activate-by-code, reset-password, change-password, update
- TestAdminEmailVerifiedAt: admin create, activate action, invitation link, email change, JSON
- TestEmailVerifiedAtMigration: column shape, no backfill, existing column kept, rollback keeps column
v0.1.5
2026-10-21 14:01:52 +02:00
Jakub Zych
a99cb467b7 fix: an email change drops the address proof
- /update clears email_verified_at in the same write when the email changes (case-only changes keep it)
- the admin Users form clears it in FormBeforeUpdate; storedEmail helper shared with guardCredentials
- README: the proof follows the address, never mass-assigned, no backfill
2026-10-21 14:01:52 +02:00
Jakub Zych
add1f7a806 feat: users.email_verified_at records a proved address (mail activation, reset code)
- additive migration 202610210001_add_users_email_verified_at (ADD COLUMN IF NOT EXISTS, no backfill, no-op rollback)
- User.EmailVerifiedAt (json:"-", not fillable)
- VerifyActivationCode and ResetPassword stamp it in the write that activates or stores the password
2026-10-21 13:53:19 +02:00
Jakub Zych
c7635c56e3 test: change-password still requires the current password (PHP map)
TestChangePassword/real_password_account_still_requires_current_password
ports the PHP OAuthPasswordBootstrapTest case: a missing or wrong
current_password is a 422 with the unchanged body, and the password stays.
2026-10-21 01:23:13 +02:00
Jakub Zych
d6d8f117ea test: provider source, unlink rule and race, sign-in helpers
- TestOAuthProviders: old bytes without a source, order, per-request lookup
- TestOAuthIdentitiesDestroyRule: the PHP last-identity rule table, 404s,
  microsoft by default, source names and opts.Providers precedence
- TestOAuthIdentitiesDestroyRace: two row-locked transactions in both orders,
  the handler waiting for the lock, concurrent handlers
- TestRegisterUserPasswordLess, TestFinishLogin, TestRegistrationExports
2026-10-21 00:49:00 +02:00
Jakub Zych
c43ba87371 fix: user:backfill-handles opens its own database
- without a published *gorm.DB the command opens one with lagoon.OpenFromApp
  for its run and closes it, as the plugin's other commands do (withDB)
- TestBackfillHandlesOpensFromConfigWhenUnpublished; README CLI note
v0.1.4
2026-10-20 23:07:14 +02:00
Jakub Zych
174161895e fix: atomic unlink with the PHP last-identity rule
- OAuthIdentitiesDestroy locks the users row (SELECT ... FOR UPDATE) and runs
  the PHP guarded DELETE in one transaction: a self-set password or another
  identity allows the unlink; otherwise 409 with the unchanged body
- the default allow-list adds microsoft, and an OAuthProviderSource's known
  names are used when the host passes no Providers
- TestOAuthIdentitiesDestroyLastIdentityWithPassword (204),
  TestOAuthIdentitiesDestroyConcurrentSmoke; README
- gofmt of the forgotPasswordBody line touched in the previous commit
2026-10-20 23:05:14 +02:00
Jakub Zych
78a867bccb feat: provider source for oauth-providers and sign-in helpers for social login
- classes.OAuthProvider and classes.OAuthProviderSource; GET /oauth-providers
  looks the source up per request and keeps the empty body without one
- exported RegistrationSettings, RegistrationsFromIP, RefreshTTL, RemoteIP
- RegisterOptions.PasswordLess (has_self_set_password=false) and FinishLogin
  (restore + reactivate mail, last_ip_address, last_seen; no event)
- TestOAuthProvidersSourceSmoke, TestSocialHooksSmoke; README
2026-10-20 23:02:37 +02:00
Jakub Zych
1fc0ce7ed0 test: TestTurnstileRoutes pins golem15.user.turnstile per mode
Replaces TestTurnstileRoutesSmoke: empty adds nothing; open, closed and a
space-padded closed add turnstile:{mode} to POST login and register only,
every other route keeps its middleware; invalid values fail naming the key.
2026-10-20 19:19:36 +02:00
Jakub Zych
7b9faa3419 feat: opt-in Cloudflare Turnstile on login and register (golem15.user.turnstile)
- empty (default) adds nothing; open or closed appends turnstile:{mode} to
  POST /login and /register only; any other value fails boot naming the key
- TestTurnstileRoutesSmoke records the declared routes with a recording router
v0.1.3
2026-10-20 14:45:22 +02:00
4c5d3b8935 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	plugin.go
v0.1.2
2026-10-19 22:35:54 +02:00
9335e80342 test: RequestUser, EndRequestSession, AvatarURL and user.Deactivating
- TestRequestUser: bearer, token and auth_token cookies in guard order;
  expired, foreign, blacklisted, revoked by tokens_valid_after and
  deactivated tokens are anonymous; only infrastructure failures error
- TestEndRequestSession (no database): jti blacklisted until the token
  expires, forget cookie with the configured Domain and Path, best effort
- TestAvatarURL: thumbnail, broken-image and storage-failure fallbacks,
  gravatar with the size, the user payload's avatar_url unchanged
- TestDeactivatingEvent: one event per newly deactivated user inside the
  bulk action's transaction, a listener error rolls the action back
2026-10-19 21:40:41 +02:00
80b436d3d9 feat: fire user.Deactivating from the admin deactivate action
- DeactivatingEvent and DeactivatingEventName in classes
- FireDeactivatingEvent modelled on FireDeletingEvent
- the deactivate bulk action runs DeactivateUsers unchanged, then fires
  the event per newly deactivated user in the action's transaction
2026-10-19 19:06:20 +02:00
ee474401cb feat: export AvatarURL for the display picture at any size
- AvatarURL returns the avatar thumbnail, the file path when the thumbnail
  fails, or the gravatar URL, the rule of the user payload
- applyAvatar takes avatar_url from it; the payload is unchanged
2026-10-19 18:55:57 +02:00
b82d85f440 feat: export RequestUser and EndRequestSession for OAuth providers
- RequestUser resolves the signed-in user exactly like the jwt guard
  (bearer, token and auth_token cookies, blacklist, tokens_valid_after)
  and returns nil for anonymous or invalid requests
- EndRequestSession blacklists the session JWT and forgets the token
  cookie with the configured cookie attributes, best effort
2026-10-19 18:42:38 +02:00
Jakub Zych
c0770294a5 feat(19-04): user authorizer, user.Deleting and handle backfill
Register lighthouse user:{id} if the namespace is free, fire user.Deleting before unscoped delete, and backfill missing handles.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 21:53:21 +02:00
Jakub Zych
15b19c7021 feat(19-04): add users.handle, HandleGenerator and user.Created
Generate a unique mention handle on API register and admin create, and fire user.Created from both paths so listeners run in the same write.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 21:44:59 +02:00
569768e657 test(updates): account for the oauth-identities migration in history and rollback counts 2026-10-11 16:35:33 +02:00
Jakub Zych
24b16b1f10 fix: open Postgres for one-shot console commands v0.1.1 2026-10-07 13:35:59 +02:00
Jakub Zych
7a6fa0e8b6 chore: pin SummerCMS v0.1.9 for v0.1 v0.1.0 2026-10-07 11:00:05 +02:00
Jakub Zych
cf5c6b9d44 fix(14.1): omit OAuth identity profile_data from JSON marshaling
Hidden() already listed profile_data, but encoding/json would still emit
the Jsonable field. Tag it json:"-" so the DATA-07 marshal backstop holds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 21:46:59 +02:00
Jakub Zych
f0d3b8b21d test(14.1-02): prove oauth-identities migration and secret-leak list
- Migration up/down covers jsonb profile_data, unique indexes, and cascade FK
- GET list with Encrypted tokens fails if plaintext, key names, or [redacted] leak

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 21:34:00 +02:00
Jakub Zych
026848fc02 test(14.1-02): port OAuthIdentityApiTest 204, 409, Winter 404s, 401
- Unlink 204 keeps the sibling row
- Last-method 409 matches EN and PL lang strings even with a password
- Missing, foreign, and unknown providers share byte-identical Winter HTML 404s
- Unauthenticated GET and DELETE /google return 401 JSON

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 21:31:50 +02:00
Jakub Zych
ac8aa16217 docs(14.1-01): document host-mounted OAuth identity constructors
The table, migration and Index/Destroy constructors are a shared-plugin
contract; the host chooses the path and 404 writer.
2026-10-05 21:24:04 +02:00
Jakub Zych
8e172c5087 feat(14.1-01): unlink OAuth identities with 204 and last-method 409
- Destroy allow-lists providers and shares WriteNotFound for unknown, missing and foreign rows
- Last remaining identity is refused with a localized 409; password flags are unused
- EN and PL last_method_blocked strings match the PHP texts
2026-10-05 21:11:24 +02:00
Jakub Zych
63ac504c42 feat(14.1-01): add OAuth identities table and empty-list Index
- Model golem15_user_oauth_identities with Encrypted tokens and empty Fillable
- gormigrate 202610050001 with both unique indexes, cascade FK, and jsonb profile_data
- OAuthIdentitiesIndex returns {"data":[]} for a signed-in caller with no rows
2026-10-05 21:09:28 +02:00
Jakub Zych
2b04cda3ff test: T-12.1-24 also marshals a user with last_seen and permissions
The removal check for the json tag of users.last_seen showed that the
subtest read the user API payloads only, which are built field by field.
It now marshals the model as well, so a tag that would expose an admin
column fails it.
2026-10-05 15:46:52 +02:00
Jakub Zych
7991b53979 test: extend the permission resolver table, the admin migration history and the API token refusals
- resolver: several groups, numbers stored as text, empty group sets, a code only at user level, booleans
- migrations: every id once, the history applied twice, the unique code after a down and an up
- personal API tokens: no application, no principal, invalid bodies, another user's token
2026-10-05 15:23:23 +02:00
Jakub Zych
a848aa17d6 test: edge cases of the Users, User Groups and Organisations admin controllers
- each controller driven directly on records of another type, an application that is not booted, one without a database and one whose database fails: a guard that cannot read answers a plain error, never a pass
- D-30: a superuser passes the takeover guard; an unreadable membership is the opaque 500 with nothing changed
- unknown partials, undeclared filters and sorts, options of fields that have none, search and sort on the three lists
- a name-only update of a privileged group, validation before the guard, a seeded group protected through the config list
- known gaps pinned: boolean values in a group's permissions, organisation_role after an unlink
2026-10-05 15:23:23 +02:00
Jakub Zych
493a3ff47c test: unit tests for the admin class functions, the models and the admin registration
- classes: every admin action for its changed-row count and idempotency, the ban across several throttle rows, the cleanup of a permanent delete
- classes: the privileged list and IsPrivilegedMember (D-30), also through a handle that carries conditions
- classes: the five-minute boundary of last_seen
- models: PermissionSet round trip, the slug table shared with the SPA, Fillable and Rules, fields that never marshal, the group filter value
- TestAdminRegistration: permissions, navigation, embedded files and phrase keys in en and pl; the admin secret is required
2026-10-05 15:15:28 +02:00
Jakub Zych
43fabfa6c3 fix: a group filter value that is not a group id lists nobody (D-23)
User.FilterScope handed the filter value to the database as it came. A
value such as abc failed the query, so the Users list answered 500.

- groupFilterID reads a positive whole number, as a number or its text
- any other value narrows the list to nothing
- TestAdminUsersGroupFilterValue fails with a 500 without the change
2026-10-05 15:15:28 +02:00
Jakub Zych
f4969591e2 fix: read privileged membership and the admin actions through a clean statement (T-12.1-38, T-12.1-39)
classes.fresh chained WithContext onto a lazy NewDB session, which clones the
caller's statement with its conditions. A handle that carried a condition
made IsPrivilegedMember answer false for a member of a privileged group, so
the takeover guard would have let the request through. No caller passes such
a handle today: the admin hooks hand over the write's transaction handle.

- fresh builds the empty statement before anything is chained onto it
- IsPrivilegedMember and PrivilegedGroupIDs read through fresh
2026-10-05 14:53:41 +02:00