Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66fc107eb9 | |||
| bd2cc0c245 |
@@ -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:<scope>` limits delegated access and `user.permission:<code>` 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:<scope>` limits delegated access and `user.permission:<code>` 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:<scope>` or `user.permission:<code>` 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
|
||||
|
||||
2
mcp.go
2
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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
88
middleware/api_token_test.go
Normal file
88
middleware/api_token_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
)
|
||||
|
||||
// forbiddenBody is the 403 body of Scope and Permission.
|
||||
type forbiddenBody struct {
|
||||
Error bool `json:"error"`
|
||||
Message string `json:"message"`
|
||||
MissingScope string `json:"missing_scope"`
|
||||
MissingPermission string `json:"missing_permission"`
|
||||
}
|
||||
|
||||
// tokenWith is an in-memory credential holding scopes, built the way
|
||||
// MCPDispatch builds it.
|
||||
func tokenWith(scopes ...string) *models.APIToken {
|
||||
return &models.APIToken{Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true}}
|
||||
}
|
||||
|
||||
// serve runs mw over a handler answering 200, with ctx on the request.
|
||||
func serve(t *testing.T, mw func(http.Handler) http.Handler, ctx context.Context) (int, forbiddenBody) {
|
||||
t.Helper()
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
||||
mw(next).ServeHTTP(rec, req)
|
||||
var body forbiddenBody
|
||||
if rec.Code == http.StatusForbidden {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("403 body %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
}
|
||||
return rec.Code, body
|
||||
}
|
||||
|
||||
// TestForbiddenBodiesNameWhatIsMissing (D-15, D-20): a refusal by Scope or
|
||||
// Permission keeps error true and the message, and adds missing_scope or
|
||||
// missing_permission naming what is missing. No database.
|
||||
func TestForbiddenBodiesNameWhatIsMissing(t *testing.T) {
|
||||
scopeRefused := forbiddenBody{Error: true, Message: "Missing required scope: write", MissingScope: "write"}
|
||||
|
||||
t.Run("scope missing from the credential", func(t *testing.T) {
|
||||
ctx := bouncer.WithCredential(context.Background(), tokenWith("read"))
|
||||
code, body := serve(t, Scope("write"), ctx)
|
||||
if code != http.StatusForbidden || body != scopeRefused {
|
||||
t.Fatalf("Scope(write) with read = %d %+v, want 403 %+v", code, body, scopeRefused)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no credential", func(t *testing.T) {
|
||||
code, body := serve(t, Scope("write"), context.Background())
|
||||
if code != http.StatusForbidden || body != scopeRefused {
|
||||
t.Fatalf("Scope(write) without a credential = %d %+v, want 403 %+v", code, body, scopeRefused)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scope held", func(t *testing.T) {
|
||||
ctx := bouncer.WithCredential(context.Background(), tokenWith("read"))
|
||||
if code, _ := serve(t, Scope("read"), ctx); code != http.StatusOK {
|
||||
t.Fatalf("Scope(read) with read = %d, want 200", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("permission missing", func(t *testing.T) {
|
||||
ctx := bouncer.WithUser(context.Background(), &bouncer.Principal{ID: 1, PermissionGrants: map[string]bool{"jz.bm.access_schedule": true}})
|
||||
want := forbiddenBody{Error: true, Message: "Forbidden.", MissingPermission: "jz.bm.access_courses"}
|
||||
code, body := serve(t, Permission("jz.bm.access_courses"), ctx)
|
||||
if code != http.StatusForbidden || body != want {
|
||||
t.Fatalf("Permission without the grant = %d %+v, want 403 %+v", code, body, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("permission granted", func(t *testing.T) {
|
||||
ctx := bouncer.WithUser(context.Background(), &bouncer.Principal{ID: 1, PermissionGrants: map[string]bool{"jz.bm.access_courses": true}})
|
||||
if code, _ := serve(t, Permission("jz.bm.access_courses"), ctx); code != http.StatusOK {
|
||||
t.Fatalf("Permission with the grant = %d, want 200", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user