336 lines
12 KiB
Go
336 lines
12 KiB
Go
package user
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"database/sql/driver"
|
|
"io"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
pgdriver "gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// impliesServersRead is the one declared scope the tracer exercises: an
|
|
// OAuth grant of read satisfies it, a grant of write does not.
|
|
func impliesServersRead() []pact.FrontendTokenScope {
|
|
return []pact.FrontendTokenScope{{
|
|
Name: "servers:read", Label: "Read servers", Description: "List servers.", Implies: "read",
|
|
}}
|
|
}
|
|
|
|
// impliesApp boots the member-token catalog with scopes declared and
|
|
// publishes a database handle the guard can load a principal from. The
|
|
// handle answers one user and no group grants, in memory, so the scope
|
|
// check can reach a credential without Postgres. Nothing is written.
|
|
func impliesApp(t *testing.T, scopes []pact.FrontendTokenScope) (*backpack.App, *classes.MCPUserGuard) {
|
|
t.Helper()
|
|
t.Cleanup(classes.ResetDeclaredAPITokenScopes)
|
|
app := scopeBootApp(t, scopeDeclarer{"acme.scopes", scopes})
|
|
if err := (&Plugin{}).bootAPITokenScopes(app); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sqlDB, err := sql.Open("user_implies", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
db, err := gorm.Open(pgdriver.New(pgdriver.Config{Conn: sqlDB}), &gorm.Config{DisableAutomaticPing: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.Publish(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return app, &classes.MCPUserGuard{App: app}
|
|
}
|
|
|
|
// TestMCPImpliesOAuth pins the OAuth half of D-01a: a grant of read satisfies
|
|
// a declared scope whose Implies is read, in MCPAllows and on the credential
|
|
// MCPDispatch builds, and that credential keeps ID 0. The grant slice itself
|
|
// is not rewritten, and the catalog is published as pact.MemberTokenScopeCatalog.
|
|
func TestMCPImpliesOAuth(t *testing.T) {
|
|
app, guard := impliesApp(t, impliesServersRead())
|
|
catalog, ok := app.Lookup[pact.MemberTokenScopeCatalog]()
|
|
if !ok || catalog == nil {
|
|
t.Fatal("pact.MemberTokenScopeCatalog is not published")
|
|
}
|
|
if _, ok := app.Lookup[*classes.APITokenScopeCatalog](); !ok {
|
|
t.Fatal("the concrete catalog publish was removed")
|
|
}
|
|
var sawRead, sawDeclared bool
|
|
for _, scope := range catalog.MemberTokenScopes() {
|
|
switch scope.Name {
|
|
case "read", "write", "ai":
|
|
if scope.Implies != "" {
|
|
t.Fatalf("built-in %s Implies = %q", scope.Name, scope.Implies)
|
|
}
|
|
if scope.Name == "read" {
|
|
sawRead = true
|
|
}
|
|
case "servers:read":
|
|
sawDeclared = scope.Implies == "read"
|
|
}
|
|
}
|
|
if !sawRead || !sawDeclared {
|
|
t.Fatalf("catalog = %+v", catalog.MemberTokenScopes())
|
|
}
|
|
|
|
ctx := context.Background()
|
|
subject := pact.MCPSubject{Guard: "user", UserID: 7, ClientID: "oauth-client", Scopes: []string{"read"}}
|
|
orig := subject.Scopes
|
|
ok, err := guard.MCPAllows(ctx, subject, pact.MCPRequirements{Scopes: []string{"servers:read"}}, nil)
|
|
if err != nil || !ok {
|
|
t.Fatalf("MCPAllows(servers:read) = %v, %v", ok, err)
|
|
}
|
|
dispatch, err := guard.MCPDispatch(ctx, subject, "GET")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
credential, isToken := dispatch.Credential.(*models.APIToken)
|
|
if !isToken || credential.ID != 0 || credential.OAuthClientID != nil || !credential.HasScope("servers:read") || !credential.HasScope("read") {
|
|
t.Fatalf("credential = %+v", dispatch.Credential)
|
|
}
|
|
if len(subject.Scopes) != 1 || &subject.Scopes[0] != &orig[0] || subject.Scopes[0] != "read" {
|
|
t.Fatalf("subject.Scopes = %v, want the original grant slice", subject.Scopes)
|
|
}
|
|
if slices.Contains(subject.Scopes, "servers:read") {
|
|
t.Fatal("implied name was written onto subject.Scopes")
|
|
}
|
|
}
|
|
|
|
// TestMCPImpliesPersonalUnchanged pins D-11: a personal token is not expanded,
|
|
// and the credential ID stays the subject's TokenID.
|
|
func TestMCPImpliesPersonalUnchanged(t *testing.T) {
|
|
_, guard := impliesApp(t, impliesServersRead())
|
|
ctx := context.Background()
|
|
subject := pact.MCPSubject{Guard: "user", UserID: 7, TokenID: 42, Scopes: []string{"read"}}
|
|
orig := subject.Scopes
|
|
ok, err := guard.MCPAllows(ctx, subject, pact.MCPRequirements{Scopes: []string{"servers:read"}}, nil)
|
|
if err != nil || ok {
|
|
t.Fatalf("MCPAllows = %v, %v", ok, err)
|
|
}
|
|
if len(subject.Scopes) != 1 || &subject.Scopes[0] != &orig[0] || subject.Scopes[0] != "read" {
|
|
t.Fatalf("subject.Scopes = %v, want the original grant slice", subject.Scopes)
|
|
}
|
|
dispatch, err := guard.MCPDispatch(ctx, subject, "GET")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
credential, isToken := dispatch.Credential.(*models.APIToken)
|
|
if !isToken || credential.ID != subject.TokenID || credential.HasScope("servers:read") || !credential.HasScope("read") {
|
|
t.Fatalf("credential = %+v, want ID %d and scopes [read]", dispatch.Credential, subject.TokenID)
|
|
}
|
|
got := credential.Scopes.Get()
|
|
if len(got) != 1 || &got[0] != &orig[0] {
|
|
t.Fatalf("credential scopes = %v, want the unexpanded grant slice", got)
|
|
}
|
|
}
|
|
|
|
// TestMCPImpliesWriteDoesNotGrantRead pins that a write grant does not
|
|
// satisfy a scope whose Implies is read.
|
|
func TestMCPImpliesWriteDoesNotGrantRead(t *testing.T) {
|
|
_, guard := impliesApp(t, impliesServersRead())
|
|
ctx := context.Background()
|
|
subject := pact.MCPSubject{Guard: "user", UserID: 7, ClientID: "oauth-client", Scopes: []string{"write"}}
|
|
ok, err := guard.MCPAllows(ctx, subject, pact.MCPRequirements{Scopes: []string{"servers:read"}}, nil)
|
|
if err != nil || ok {
|
|
t.Fatalf("MCPAllows = %v, %v", ok, err)
|
|
}
|
|
dispatch, err := guard.MCPDispatch(ctx, subject, "GET")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
credential, isToken := dispatch.Credential.(*models.APIToken)
|
|
if !isToken || credential.ID != 0 || credential.HasScope("servers:read") || !credential.HasScope("write") {
|
|
t.Fatalf("credential = %+v", dispatch.Credential)
|
|
}
|
|
}
|
|
|
|
// TestMCPImpliesNotPersisted pins that MCPDispatch expands scopes only on
|
|
// the credential it returns. The subject's grant slice is the same backing
|
|
// array afterwards.
|
|
func TestMCPImpliesNotPersisted(t *testing.T) {
|
|
_, guard := impliesApp(t, impliesServersRead())
|
|
ctx := context.Background()
|
|
subject := pact.MCPSubject{Guard: "user", UserID: 7, ClientID: "oauth-client", Scopes: []string{"read"}}
|
|
orig := subject.Scopes
|
|
dispatch, err := guard.MCPDispatch(ctx, subject, "GET")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
credential, isToken := dispatch.Credential.(*models.APIToken)
|
|
if !isToken || !credential.HasScope("servers:read") || !credential.HasScope("read") {
|
|
t.Fatalf("credential = %+v, want the expanded scopes on the credential only", dispatch.Credential)
|
|
}
|
|
if len(subject.Scopes) != 1 || &subject.Scopes[0] != &orig[0] || subject.Scopes[0] != "read" {
|
|
t.Fatalf("subject.Scopes = %v, want the original grant slice", subject.Scopes)
|
|
}
|
|
if slices.Contains(subject.Scopes, "servers:read") {
|
|
t.Fatal("implied name was written onto subject.Scopes")
|
|
}
|
|
}
|
|
|
|
// TestMCPImpliesAlreadyPresent does not append a declared name the grant
|
|
// already holds, and does not append a catalog name whose Implies is empty.
|
|
func TestMCPImpliesAlreadyPresent(t *testing.T) {
|
|
_, guard := impliesApp(t, []pact.FrontendTokenScope{
|
|
{Name: "servers:read", Label: "Read servers", Description: "List servers.", Implies: "read"},
|
|
{Name: "servers:list", Label: "List servers", Description: "List only.", Implies: ""},
|
|
})
|
|
ctx := context.Background()
|
|
subject := pact.MCPSubject{Guard: "user", UserID: 7, ClientID: "oauth-client", Scopes: []string{"read", "servers:read"}}
|
|
dispatch, err := guard.MCPDispatch(ctx, subject, "GET")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
credential, isToken := dispatch.Credential.(*models.APIToken)
|
|
if !isToken || !slices.Equal(credential.Scopes.Get(), []string{"read", "servers:read"}) {
|
|
t.Fatalf("credential scopes = %+v", dispatch.Credential)
|
|
}
|
|
}
|
|
|
|
// TestMCPImpliesCompileRejects pins the boot errors for a bad Implies and
|
|
// for the reserved names backend and offline_access. The error names the
|
|
// plugin and the bad value. Empty, read, and write still compile.
|
|
func TestMCPImpliesCompileRejects(t *testing.T) {
|
|
t.Cleanup(classes.ResetDeclaredAPITokenScopes)
|
|
one := func(name, implies string) pact.FrontendTokenScope {
|
|
return pact.FrontendTokenScope{Name: name, Label: "Label", Description: "Description", Implies: implies}
|
|
}
|
|
boot := func(scope pact.FrontendTokenScope) error {
|
|
classes.ResetDeclaredAPITokenScopes()
|
|
return (&Plugin{}).bootAPITokenScopes(scopeBootApp(t, scopeDeclarer{"acme.scopes", []pact.FrontendTokenScope{scope}}))
|
|
}
|
|
for _, implies := range []string{"ai", "backend", "READ", " read"} {
|
|
err := boot(one("servers:read", implies))
|
|
if err == nil || !strings.Contains(err.Error(), "acme.scopes") || !strings.Contains(err.Error(), implies) {
|
|
t.Fatalf("implies %q err = %v", implies, err)
|
|
}
|
|
}
|
|
for _, name := range []string{"backend", "offline_access"} {
|
|
err := boot(one(name, ""))
|
|
if err == nil || !strings.Contains(err.Error(), "acme.scopes") || !strings.Contains(err.Error(), `"`+name+`"`) {
|
|
t.Fatalf("name %q err = %v", name, err)
|
|
}
|
|
}
|
|
for _, implies := range []string{"", "read", "write"} {
|
|
if err := boot(one("servers:read", implies)); err != nil {
|
|
t.Fatalf("implies %q: %v", implies, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func init() {
|
|
sql.Register("user_implies", impliesDriver{})
|
|
}
|
|
|
|
// impliesDriver is an in-memory database/sql driver for the Implies tests.
|
|
// A users query returns one row. A group-grant query returns none.
|
|
type impliesDriver struct{}
|
|
|
|
func (impliesDriver) Open(string) (driver.Conn, error) { return impliesConn{}, nil }
|
|
|
|
type impliesConn struct{}
|
|
|
|
func (impliesConn) Prepare(string) (driver.Stmt, error) {
|
|
return nil, driver.ErrSkip
|
|
}
|
|
func (impliesConn) Close() error { return nil }
|
|
func (impliesConn) Begin() (driver.Tx, error) { return nil, driver.ErrSkip }
|
|
|
|
func (impliesConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) {
|
|
return impliesRowsFor(query), nil
|
|
}
|
|
|
|
func (impliesConn) ExecContext(context.Context, string, []driver.NamedValue) (driver.Result, error) {
|
|
return impliesResult(0), nil
|
|
}
|
|
|
|
func impliesRowsFor(query string) driver.Rows {
|
|
upper := strings.ToUpper(query)
|
|
if strings.Contains(upper, "FROM \"USERS\"") || strings.Contains(upper, "FROM USERS") {
|
|
cols := impliesSelectCols(query)
|
|
vals := make([]driver.Value, len(cols))
|
|
for i, col := range cols {
|
|
vals[i] = impliesColValue(col)
|
|
}
|
|
return &impliesRows{cols: cols, vals: [][]driver.Value{vals}}
|
|
}
|
|
return &impliesRows{cols: []string{"permissions"}}
|
|
}
|
|
|
|
func impliesSelectCols(query string) []string {
|
|
upper := strings.ToUpper(query)
|
|
start := strings.Index(upper, "SELECT")
|
|
from := strings.Index(upper, " FROM ")
|
|
if start < 0 || from < 0 || from <= start+6 {
|
|
return []string{"id"}
|
|
}
|
|
raw := strings.Split(query[start+6:from], ",")
|
|
cols := make([]string, 0, len(raw))
|
|
for _, part := range raw {
|
|
name := strings.TrimSpace(part)
|
|
name = strings.Trim(name, `"`)
|
|
if i := strings.LastIndex(name, "."); i >= 0 {
|
|
name = name[i+1:]
|
|
}
|
|
name = strings.Trim(name, `"`)
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || name == "*" {
|
|
continue
|
|
}
|
|
cols = append(cols, name)
|
|
}
|
|
if len(cols) == 0 {
|
|
return []string{"id"}
|
|
}
|
|
return cols
|
|
}
|
|
|
|
func impliesColValue(col string) driver.Value {
|
|
switch col {
|
|
case "id":
|
|
return int64(7)
|
|
case "email":
|
|
return "ada@example.test"
|
|
case "password":
|
|
return "x"
|
|
case "must_change_password", "is_activated", "has_self_set_password", "marketing_consent", "is_onboarded":
|
|
return false
|
|
case "created_at", "updated_at":
|
|
return time.Time{}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
type impliesRows struct {
|
|
cols []string
|
|
vals [][]driver.Value
|
|
i int
|
|
}
|
|
|
|
func (r *impliesRows) Columns() []string { return r.cols }
|
|
func (r *impliesRows) Close() error { return nil }
|
|
func (r *impliesRows) Next(dest []driver.Value) error {
|
|
if r.i >= len(r.vals) {
|
|
return io.EOF
|
|
}
|
|
copy(dest, r.vals[r.i])
|
|
r.i++
|
|
return nil
|
|
}
|
|
|
|
type impliesResult int64
|
|
|
|
func (r impliesResult) LastInsertId() (int64, error) { return int64(r), nil }
|
|
func (r impliesResult) RowsAffected() (int64, error) { return int64(r), nil }
|