package cabana import ( "context" "crypto/subtle" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/towel" ) // resolverTokenPath is the path logTokenAuthAt records for a token check that // has no request behind it (an MCP session opened with a personal token). const resolverTokenPath = "mcp" // ResolveBackendToken resolves a g15_ personal API token outside any request: // the same hash lookup, constant-time compare, usability window, admin load // and last_used_at stamp as the backend guard, but without the request-method // scope filter. It returns the admin principal (without Scope), the token row // and the scope names stored on the token, so an in-process dispatcher can // compute the limit per dispatched route method with ScopeLimitFor. A // malformed, unknown, revoked or expired token, a missing or deactivated // admin and an application without cabana activated all answer // ErrUnauthenticated; the audit line is logged under path "mcp". func ResolveBackendToken(ctx context.Context, app *backpack.App, raw string) (*bouncer.Principal, *BackendAPIToken, []string, error) { // The caller's client address, when it put one on the context (pier // does), so a failed attempt is traceable to an address. remote, _ := towel.ClientIP(ctx) if ctx == nil { ctx = context.Background() } guard := publishedResolverGuard(app) if guard == nil || !wellFormedAPIToken(raw) || guard.store == nil { logTokenAuthAt("", resolverTokenPath, remote, "failed", 0, 0) return nil, nil, nil, ErrUnauthenticated } now := time.Now().UTC() hash := hashAPIToken(raw) row, err := guard.store.FindUsableByHash(ctx, hash, now) if err != nil || row == nil { logTokenAuthAt("", resolverTokenPath, remote, "failed", 0, 0) return nil, nil, nil, ErrUnauthenticated } if subtle.ConstantTimeCompare([]byte(row.TokenHash), []byte(hash)) != 1 { logTokenAuthAt("", resolverTokenPath, remote, "failed", 0, row.ID) return nil, nil, nil, ErrUnauthenticated } if guard.users == nil { logTokenAuthAt("", resolverTokenPath, remote, "failed", 0, row.ID) return nil, nil, nil, ErrUnauthenticated } principal, err := guard.users.FindByID(ctx, row.BackendUserID) if err != nil || principal == nil { logTokenAuthAt("", resolverTokenPath, remote, "failed", row.BackendUserID, row.ID) return nil, nil, nil, ErrUnauthenticated } stored := row.Scopes.Get() if !row.Scopes.Valid { stored = nil } // Personal API tokens keep the request locale (see authenticateToken). principal.PreferredLocale = "" stampLastUsed(ctx, guard.stamps, guard.store, row.ID, now) logTokenAuthAt("", resolverTokenPath, remote, "success", principal.ID, row.ID) return principal, row, append([]string(nil), stored...), nil } // ScopeLimitFor filters the stored scope names of a personal API token by one // HTTP method, exactly as the backend guard does for a request: the limit to // put on bouncer.Principal.Scope, the number of names the registry knows and // the number of those that allow the method. A nil limit with known 0 means // no stored scope exists any more (unauthenticated); known > 0 with methodOK // 0 means the token has no scope for that method (token_scope). The second // and third results are 0 when cabana is not activated on app. func ScopeLimitFor(app *backpack.App, scopes []string, method string) (*bouncer.ScopeLimit, int, int) { guard := publishedResolverGuard(app) if guard == nil || guard.reg == nil { return nil, 0, 0 } return methodFilterScopes(guard.reg.tokenScopes, scopes, method) } // BackendPrincipal loads the admin principal with the given id through the // backend users provider cabana published at activation: Backend, // IsSuperuser and PermissionGrants are set as for a signed-in admin. It // answers nil, nil when the admin does not exist or is deactivated, and an // error when cabana is not activated on app or the database is unavailable. func BackendPrincipal(ctx context.Context, app *backpack.App, id uint) (*bouncer.Principal, error) { if ctx == nil { ctx = context.Background() } guard := publishedResolverGuard(app) if guard == nil || guard.users == nil { return nil, ErrUnauthenticated } return guard.users.FindByID(ctx, id) } // publishedResolverGuard returns the composite guard behind the published // cookie-less resolver, or nil when cabana has not activated on app. func publishedResolverGuard(app *backpack.App) *compositeGuard { if app == nil { return nil } res, ok := app.Lookup[*backendResolver]() if !ok || res == nil || res.guard == nil { return nil } return res.guard }