4 Commits

Author SHA1 Message Date
Jakub Zych
91af10b572 chore: require SummerCMS v0.5.2 2026-10-28 23:39:33 +01:00
Jakub Zych
8446a77605 Merge phase-32.1-mcp-implied-scopes: implied MCP scopes alongside the 403 missing scope fields 2026-10-28 23:36:43 +01:00
66fc107eb9 feat: name the missing scope or permission in 403 bodies
A refusal by user.scope:<scope> now answers 403 with missing_scope and a
refusal by user.permission:<code> with missing_permission, next to the
unchanged error (true) and message, so a plain-HTTP script or a personal
token client learns what it lacks. user_me says it needs scope read.
2026-10-28 14:05:57 +01:00
bd2cc0c245 test: expect the missing scope or permission in 403 bodies 2026-10-28 14:05:28 +01:00
6 changed files with 98 additions and 25 deletions

View File

@@ -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. - **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. - **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). - **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`: - **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 ```go

10
go.mod
View File

@@ -3,20 +3,20 @@ module git.golem15.com/golem15/sm-user-plugin
go 1.27.0 go 1.27.0
require ( require (
git.golem15.com/golem15/summercms v0.5.0 git.golem15.com/golem15/summercms v0.5.2
github.com/go-gormigrate/gormigrate/v2 v2.1.7 github.com/go-gormigrate/gormigrate/v2 v2.1.7
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/pgx/v5 v5.10.0
github.com/testcontainers/testcontainers-go v0.44.0 github.com/testcontainers/testcontainers-go v0.44.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
gocloud.dev v0.46.0 gocloud.dev v0.46.0
gorm.io/driver/postgres v1.6.3
gorm.io/gorm v1.31.2 gorm.io/gorm v1.31.2
) )
require ( require (
charm.land/bubbles/v2 v2.2.1 // indirect charm.land/bubbles/v2 v2.2.1 // indirect
charm.land/bubbletea/v2 v2.1.0 // indirect charm.land/bubbletea/v2 v2.1.0 // indirect
charm.land/fang/v2 v2.0.1 // indirect
charm.land/huh/v2 v2.0.3 // indirect charm.land/huh/v2 v2.0.3 // indirect
charm.land/lipgloss/v2 v2.0.6 // indirect charm.land/lipgloss/v2 v2.0.6 // indirect
dario.cat/mergo v1.0.2 // indirect dario.cat/mergo v1.0.2 // indirect
@@ -50,7 +50,6 @@ require (
github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
github.com/charmbracelet/x/ansi v0.11.9 // indirect github.com/charmbracelet/x/ansi v0.11.9 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
@@ -120,10 +119,6 @@ require (
github.com/moby/term v0.5.2 // indirect github.com/moby/term v0.5.2 // indirect
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/mango v0.1.0 // indirect
github.com/muesli/mango-cobra v1.2.0 // indirect
github.com/muesli/mango-pflag v0.1.0 // indirect
github.com/muesli/roff v0.1.0 // indirect
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect
@@ -177,7 +172,6 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
google.golang.org/grpc v1.79.3 // indirect google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gorm.io/driver/postgres v1.6.3 // indirect
) )
replace git.golem15.com/golem15/summercms => ../summercms.go replace git.golem15.com/golem15/summercms => ../summercms.go

12
go.sum
View File

@@ -4,8 +4,6 @@ charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw= charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU= charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
@@ -110,8 +108,6 @@ github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
@@ -299,14 +295,6 @@ github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtw
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=

2
mcp.go
View File

@@ -15,7 +15,7 @@ func (p *Plugin) MCP() pact.MCP {
return pact.MCP{Tools: []pact.MCPTool{{ return pact.MCP{Tools: []pact.MCPTool{{
Name: "me", Name: "me",
Title: "Current user", 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", Guard: "user",
Method: http.MethodGet, Method: http.MethodGet,
Path: "/_user/api/v1/token/me", Path: "/_user/api/v1/token/me",

View File

@@ -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."}) 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 { func Scope(scope string) pact.Middleware {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
credential, _ := bouncer.Credential(r.Context()) credential, _ := bouncer.Credential(r.Context())
token, ok := credential.(*models.APIToken) token, ok := credential.(*models.APIToken)
if !ok || token == nil || !token.HasScope(scope) { 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 return
} }
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
@@ -103,12 +104,14 @@ func Scope(scope string) pact.Middleware {
// Permission requires one current user-group permission. Tokens never carry // Permission requires one current user-group permission. Tokens never carry
// a permission snapshot: revoking the user's grant takes effect immediately. // 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 { func Permission(code string) pact.Middleware {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, _ := bouncer.User(r.Context()) principal, _ := bouncer.User(r.Context())
if !cabana.Allows(principal, []string{code}) { 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 return
} }
next.ServeHTTP(w, r) next.ServeHTTP(w, r)

View 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)
}
})
}