diff --git a/README.md b/README.md index 5ec9db3..3534ace 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap - **Address proof.** `users.email_verified_at` records when the account's owner proved control of the address (`User.EmailVerifiedAt`). Exactly two flows write it: the mailed activation code (`POST /activate`, `POST /activate-by-code` and the invitation link, through `VerifyActivationCode`) and a completed reset-code password reset (`POST /reset-password`). `POST /register` in `auto` or `admin` mode, `RegisterUser`, an administrator's create and the admin Activate action, `POST /change-password` and social sign-ins record none. The proof follows the address: an email change through `POST /update` or the admin Users form clears it, while a change of letter case only keeps it. The column is never mass-assigned and is in no user API payload, admin form or list. No migration or code path backfills it: accounts from before v0.1.5 have no proof. An application that trusts its imported addresses may set the column in its own data migration; this plugin does not. A social-login plugin links a verified provider's identity onto an existing account only when it is set. - **Handle.** `users.handle` is a unique nullable `VARCHAR(255)` generated on create (`classes.HandleGenerator`, charset `[a-z0-9._-]`, length 2–255). It is omitted from the user JSON payload (`json:"-"`). `user:backfill-handles` fills existing rows that still have none. - **User lighthouse authorizer.** Boot calls `classes.RegisterUserAuthorizer`: `lighthouse.From` then `Registry.Register("user", …)` if the namespace is free. `classes.AuthorizeUser` allows only `user:{id}` for that user (`lighthouse.ChannelID` on the two-segment name). -- **Shared API tokens.** `user_api_tokens` stores one hashed personal/OAuth credential per user for the whole application. The `user.api_token` guard authenticates it for any plugin; `user.scope:` limits delegated access and `user.permission:` checks the user's current group permissions. `POST/GET/DELETE /tokens` manage personal tokens, and `user:token:create`, `user:token:list` and `user:token:revoke` provide the CLI equivalent. A raw secret is shown only when created. +- **Shared API tokens.** `user_api_tokens` stores one hashed personal/OAuth credential per user for the whole application. The `user.api_token` guard authenticates it for any plugin; `user.scope:` limits delegated access and `user.permission:` checks the user's current group permissions. `POST/GET/DELETE /tokens` manage personal tokens, and `user:token:create`, `user:token:list` and `user:token:revoke` provide the CLI equivalent. A raw secret is shown only when created. A refusal by `user.scope:` or `user.permission:` answers 403 with the existing `error` and `message` plus `missing_scope` or `missing_permission` naming what is missing. - **Member token scopes.** A member personal token (`usr_`) carries the built-in scopes `read`, `write` and `ai`, plus any scope another plugin declares with `pact.HasFrontendTokenScopes`: ```go diff --git a/mcp.go b/mcp.go index 9444db8..e0c2af8 100644 --- a/mcp.go +++ b/mcp.go @@ -15,7 +15,7 @@ func (p *Plugin) MCP() pact.MCP { return pact.MCP{Tools: []pact.MCPTool{{ Name: "me", Title: "Current user", - Description: "Returns the profile of the signed-in frontend user.", + Description: "Returns the profile of the signed-in frontend user. Needs scope read.", Guard: "user", Method: http.MethodGet, Path: "/_user/api/v1/token/me", diff --git a/middleware/api_token.go b/middleware/api_token.go index 83aedbc..ef0e69d 100644 --- a/middleware/api_token.go +++ b/middleware/api_token.go @@ -86,14 +86,15 @@ func (g *APITokenGuard) WriteUnauthorized(w http.ResponseWriter, _ error) { wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Invalid token."}) } -// Scope requires one delegated scope from the shared token credential. +// Scope requires one delegated scope from the shared token credential. A +// refusal answers 403 with error, message and missing_scope naming the scope. func Scope(scope string) pact.Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { credential, _ := bouncer.Credential(r.Context()) token, ok := credential.(*models.APIToken) if !ok || token == nil || !token.HasScope(scope) { - wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Missing required scope: " + scope}) + wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Missing required scope: " + scope, "missing_scope": scope}) return } next.ServeHTTP(w, r) @@ -103,12 +104,14 @@ func Scope(scope string) pact.Middleware { // Permission requires one current user-group permission. Tokens never carry // a permission snapshot: revoking the user's grant takes effect immediately. +// A refusal answers 403 with error, message and missing_permission naming the +// code. func Permission(code string) pact.Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { principal, _ := bouncer.User(r.Context()) if !cabana.Allows(principal, []string{code}) { - wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Forbidden."}) + wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Forbidden.", "missing_permission": code}) return } next.ServeHTTP(w, r)