feat: account menu, database roles, and single logout

Bundles the console work from the current branch: the header account
control, database-backed roles and permissions, and signing out across
the applications that share this sign-in.

The account control replaces a separate identity chip and a separate
sign-out button, which meant the header carried two controls describing
one fact. web/account.js is mounted into every page by a placeholder and
owns the keyboard handling, the outside-click behaviour and the sign-out
form once. It also fills the trigger's name, which the previous markup
left empty.

Signing out revokes the hosted-login bearer sessions this installation
issued, then redirects to the provider's end_session_endpoint when the
discovery document advertises one. Applications that authenticate
against this installation therefore lose their session too; one pointed
straight at an identity provider is out of its reach and is unchanged.

Roles and permissions become rows rather than a closed enum, so a tenant
can extend the catalog without a migration, and a role can no longer be
granted a capability that is not in it. requireSameOrigin now compares
against the origin the request arrived on instead of the configured base
URL, which defaulted to http://localhost:8080 and so rejected every
write in a deployment that never set it.

Two bugs found while verifying in a browser:

  - A profile picture never rendered. avatarURL built an absolute URL
    from the configured base URL, and the page's own img-src 'self'
    blocked it. The console's own pages now take a root-relative path;
    only /connect/userinfo, read from another origin, needs an absolute
    one, and it follows the request.
  - absoluteURL preferred the configuration over the request while its
    comment claimed the opposite, which also misdirected the logout
    redirect.

Card layout: the footer was one two-column row shared between a label
and a forty-character identifier, so the identifier took the width and
crushed its label into three stacked words. Each pair is a line now, the
value truncates with the full text in the title, and cards align their
footers across a row.
This commit is contained in:
Daniel Noulet 2026-10-05 19:18:07 +02:00
parent 3fdc8cb489
commit c7c81fb65c
43 changed files with 2248 additions and 298 deletions

View file

@ -29,24 +29,33 @@ type Config struct {
BootstrapTenant string
BootstrapApp string
HTTPTimeout time.Duration
// ThemeBaseURL is the go-bananas runtime origin supplying the shared styling
// contract. Empty means this application keeps its own hand-written theme
// blocks, which is the safe default: a missing contract must degrade to the
// application's existing look, not to an unstyled page.
ThemeBaseURL string
}
func Load() (Config, error) {
baseURL := strings.TrimRight(env("GO_LOOSE_BASE_URL", "http://localhost:8080"), "/")
cfg := Config{
Addr: env("GO_LOOSE_ADDR", ":8080"),
BaseURL: baseURL,
AuthDomain: strings.TrimPrefix(strings.ToLower(env("GO_LOOSE_AUTH_DOMAIN", "auth.dev")), "."),
DatabaseURL: env("GO_LOOSE_DATABASE_URL", "postgres://goloose:goloose@localhost:5432/goloose?sslmode=disable"),
SessionSecret: os.Getenv("GO_LOOSE_SESSION_SECRET"),
DevLogin: envBool("GO_LOOSE_DEV_LOGIN", false),
DevUser: env("GO_LOOSE_DEV_USER", "admin@local.test"),
LocalLogin: envBool("GO_LOOSE_LOCAL_LOGIN", false),
LocalAdminEmail: strings.ToLower(env("GO_LOOSE_LOCAL_ADMIN_EMAIL", "admin@goloose.local")),
LocalAdminPassword: os.Getenv("GO_LOOSE_LOCAL_ADMIN_PASSWORD"),
OIDCIssuer: strings.TrimRight(os.Getenv("GO_LOOSE_OIDC_ISSUER"), "/"),
OIDCClientID: os.Getenv("GO_LOOSE_OIDC_CLIENT_ID"),
OIDCClientSecret: os.Getenv("GO_LOOSE_OIDC_CLIENT_SECRET"),
Addr: env("GO_LOOSE_ADDR", ":8080"),
BaseURL: baseURL,
AuthDomain: strings.TrimPrefix(strings.ToLower(env("GO_LOOSE_AUTH_DOMAIN", "auth.dev")), "."),
DatabaseURL: env("GO_LOOSE_DATABASE_URL", "postgres://goloose:goloose@localhost:5432/goloose?sslmode=disable"),
SessionSecret: os.Getenv("GO_LOOSE_SESSION_SECRET"),
DevLogin: envBool("GO_LOOSE_DEV_LOGIN", false),
DevUser: env("GO_LOOSE_DEV_USER", "admin@local.test"),
LocalLogin: envBool("GO_LOOSE_LOCAL_LOGIN", false),
LocalAdminEmail: strings.ToLower(env("GO_LOOSE_LOCAL_ADMIN_EMAIL", "admin@goloose.local")),
LocalAdminPassword: os.Getenv("GO_LOOSE_LOCAL_ADMIN_PASSWORD"),
OIDCIssuer: strings.TrimRight(os.Getenv("GO_LOOSE_OIDC_ISSUER"), "/"),
OIDCClientID: os.Getenv("GO_LOOSE_OIDC_CLIENT_ID"),
OIDCClientSecret: os.Getenv("GO_LOOSE_OIDC_CLIENT_SECRET"),
// Trailing slashes are trimmed because every use is path concatenation, and a
// double slash would produce a URL the service answers 404 for.
ThemeBaseURL: strings.TrimRight(os.Getenv("GO_LOOSE_THEME_BASE_URL"), "/"),
OIDCRedirectURL: env("GO_LOOSE_OIDC_REDIRECT_URL", baseURL+"/auth/callback"),
AllowedEmailDomains: csv(os.Getenv("GO_LOOSE_ALLOWED_EMAIL_DOMAINS")),
AllowPrivateContractURL: envBool("GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS", false),

View file

@ -0,0 +1,47 @@
-- +goose Up
-- Permissions become rows so the catalog can be extended without a migration.
-- The nine names the application enforces in SQL are seeded here; a tenant may
-- add further names, which are grantable and reported through /connect/userinfo
-- but are not checked by the console itself.
CREATE TABLE permissions (
name text PRIMARY KEY CHECK (name ~ '^[a-z0-9][a-z0-9]*(?:[.-][a-z0-9]+)*$'),
label text NOT NULL CHECK (length(btrim(label)) BETWEEN 1 AND 100),
description text NOT NULL DEFAULT '',
is_system boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO permissions (name, label, description, is_system) VALUES
('tenants.manage', 'Manage tenants', 'Create tenants and administer the installation.', true),
('roles.manage', 'Manage roles', 'Create and edit the roles of this tenant.', true),
('users.manage', 'Manage members', 'Invite members and change their roles and application grants.', true),
('applications.manage', 'Manage applications', 'Register applications and configure client credentials.', true),
('keys.manage', 'Manage API keys', 'Issue and revoke API keys.', true),
('contracts.manage', 'Manage contracts', 'Import OpenAPI contracts and their endpoints.', true),
('users.read', 'Read members', 'List the members of a tenant and the roles they hold.', true),
('console.read', 'Read console', 'Open the management console and pick tenants.', true),
('profile.edit', 'Edit own profile', 'Change one''s own display name and picture.', true);
-- The closed list becomes a foreign key, so a role can no longer be granted a
-- capability that is not in the catalog. Granting one is now a matter of
-- inserting the permission first rather than editing a check constraint.
ALTER TABLE role_permissions DROP CONSTRAINT role_permissions_permission_check;
ALTER TABLE role_permissions
ADD CONSTRAINT role_permissions_permission_fkey
FOREIGN KEY (permission) REFERENCES permissions(name) ON DELETE RESTRICT;
-- +goose Down
DELETE FROM role_permissions WHERE permission NOT IN (
'tenants.manage', 'roles.manage', 'users.manage', 'applications.manage',
'keys.manage', 'contracts.manage', 'users.read', 'console.read', 'profile.edit'
);
ALTER TABLE role_permissions DROP CONSTRAINT role_permissions_permission_fkey;
ALTER TABLE role_permissions
ADD CONSTRAINT role_permissions_permission_check CHECK (permission IN (
'tenants.manage', 'roles.manage', 'users.manage', 'applications.manage',
'keys.manage', 'contracts.manage', 'users.read', 'console.read', 'profile.edit'
));
DROP TABLE permissions;

View file

@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE installation_settings ADD COLUMN oidc_redirect_url text NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE installation_settings DROP COLUMN IF EXISTS oidc_redirect_url;

View file

@ -0,0 +1,366 @@
package server
import (
"context"
"database/sql"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
neturl "net/url"
"os"
"strings"
"testing"
"golang.org/x/oauth2"
"github.com/danyel/go-loose/internal/config"
"github.com/danyel/go-loose/internal/database"
"github.com/danyel/go-loose/internal/session"
"github.com/danyel/go-loose/internal/store"
"github.com/danyel/go-loose/web"
)
// logoutServer builds a server with a session manager so logout can be exercised
// end to end. The store is nil, so the client-session revocation is expected to
// panic-free no-op only where it is guarded; these tests therefore exercise the
// redirect behaviour, which is what a person sees.
func logoutServer(t *testing.T) *Server {
t.Helper()
secret := strings.Repeat("s", 32)
return &Server{
cfg: config.Config{BaseURL: "https://auth.example.test", AuthDomain: "auth.example.test"},
sessions: session.New(secret, false),
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
}
// TestLogoutClearsTheSessionAndReturnsToSignIn is the baseline: with no identity
// provider logout endpoint configured, signing out lands back on the sign-in page.
func TestLogoutClearsTheSessionAndReturnsToSignIn(t *testing.T) {
server := logoutServer(t)
response := httptest.NewRecorder()
server.authLogout(response, httptest.NewRequest(http.MethodPost, "https://auth.example.test/auth/logout", nil))
if response.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want %d", response.Code, http.StatusSeeOther)
}
if location := response.Header().Get("Location"); location != "/login" {
t.Fatalf("location = %q, want /login", location)
}
var cleared bool
for _, cookie := range response.Result().Cookies() {
if cookie.MaxAge < 0 {
cleared = true
}
}
if !cleared {
t.Error("logout did not clear the session cookie")
}
}
// TestLogoutEndsTheProviderSessionWhenAdvertised covers the cross-application half:
// when the provider publishes an end_session_endpoint the browser is sent there, so
// the single sign-on session the other applications share is ended too.
func TestLogoutEndsTheProviderSessionWhenAdvertised(t *testing.T) {
server := logoutServer(t)
server.endSession = "https://idp.example.test/protocol/openid-connect/logout"
server.oauth = &oauth2ConfigStub
response := httptest.NewRecorder()
server.authLogout(response, httptest.NewRequest(http.MethodPost, "https://auth.example.test/auth/logout", nil))
location := response.Header().Get("Location")
if !strings.HasPrefix(location, server.endSession) {
t.Fatalf("location = %q, want the provider logout endpoint", location)
}
for _, want := range []string{
"client_id=go-loose",
"post_logout_redirect_uri=https%3A%2F%2Fauth.example.test%2Flogin",
} {
if !strings.Contains(location, want) {
t.Errorf("location = %q, want it to contain %q", location, want)
}
}
}
// TestLogoutSkipsTheProviderWhenItIsUnconfigured documents the fallback: a
// deployment whose provider does not advertise a logout endpoint still signs the
// user out locally rather than erroring.
func TestLogoutSkipsTheProviderWhenItIsUnconfigured(t *testing.T) {
for name, configure := range map[string]func(*Server){
"no endpoint": func(s *Server) {},
"no client id": func(s *Server) { s.endSession = "https://idp.example.test/logout" },
"unparseable": func(s *Server) { s.endSession = "://nope"; s.oauth = &oauth2ConfigStub },
"nothing set up": func(s *Server) { s.endSession = "" },
} {
t.Run(name, func(t *testing.T) {
server := logoutServer(t)
configure(server)
response := httptest.NewRecorder()
server.authLogout(response, httptest.NewRequest(http.MethodPost, "https://auth.example.test/auth/logout", nil))
if location := response.Header().Get("Location"); location != "/login" {
t.Fatalf("location = %q, want the local fallback /login", location)
}
})
}
}
// TestAccountMenuIsEmbedded guards the component: it is mounted by every page, so a
// page that forgets the placeholder loses the whole control.
func TestAccountMenuIsEmbedded(t *testing.T) {
for _, page := range []string{"index.html", "profile.html", "roles.html"} {
t.Run(page, func(t *testing.T) {
content, err := web.Files.ReadFile(page)
if err != nil {
t.Fatalf("%s must be embedded: %v", page, err)
}
text := string(content)
for _, marker := range []string{"data-account-menu", "/assets/account.js"} {
if !strings.Contains(text, marker) {
t.Errorf("%s is missing %q", page, marker)
}
}
// The account control replaces the separate sign-out button, so the
// page must not still carry one or the header shows two of each.
if strings.Contains(text, `<form action="/auth/logout"`) {
t.Errorf("%s still renders its own logout form", page)
}
if strings.Contains(text, `id="identity"`) {
t.Errorf("%s still renders the old identity chip", page)
}
})
}
}
func TestAccountMenuScriptIsServed(t *testing.T) {
server := &Server{cfg: config.Config{BaseURL: "https://auth.example.test"}}
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/assets/account.js", nil)
request.SetPathValue("name", "account.js")
response := httptest.NewRecorder()
server.asset(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", response.Code, http.StatusOK)
}
body := response.Body.String()
// The sign-out item has to be a form post, not a fetch, so that signing out
// still works with scripting unavailable.
if !strings.Contains(body, `method="post"`) || !strings.Contains(body, "/auth/logout") {
t.Error("the account menu must post to /auth/logout to sign out")
}
}
// TestRowActionsDoNotUseCardStyling pins the table fix. .card-action is
// width:100% with a 16px top margin for the foot of a card, which made two buttons
// stacked in a narrow cell taller than the rest of the row.
func TestRowActionsDoNotUseCardStyling(t *testing.T) {
for _, page := range []string{"app.js", "roles.js"} {
content, err := web.Files.ReadFile(page)
if err != nil {
t.Fatalf("%s must be embedded: %v", page, err)
}
if strings.Contains(string(content), "table-actions") == false {
t.Errorf("%s does not use the table action wrapper", page)
}
}
css, err := web.Files.ReadFile("style.css")
if err != nil {
t.Fatalf("style.css must be embedded: %v", err)
}
if !strings.Contains(string(css), ".table-action{") {
t.Error("style.css has no .table-action rule, so row buttons would be unstyled")
}
}
// oauth2ConfigStub carries the client id logout needs without reaching a provider.
var oauth2ConfigStub = oauth2.Config{ClientID: "go-loose"}
// TestLogoutRevokesHostedLoginSessions is the test that matters for cross-application
// sign-out. Go Loose issues the bearer tokens the tenant applications authenticate
// with, so revoking them here is what makes those applications reject the user
// without ever being told. Both halves need real rows: a real session cookie and a
// real client session.
func TestLogoutRevokesHostedLoginSessions(t *testing.T) {
ctx := t.Context()
// The store is opened over PostgreSQL, which is what this project uses; there is
// no in-memory engine to substitute. The test skips when none is reachable rather
// than failing, so the rest of the suite still runs without a database.
db, err := database.Open(ctx, testDatabaseURL(t))
if err != nil {
t.Skipf("no test database available: %v", err)
}
defer db.Close()
if err := resetSchema(ctx, db); err != nil {
t.Fatalf("reset schema: %v", err)
}
if err := database.Migrate(ctx, db); err != nil {
t.Fatalf("migrate: %v", err)
}
st := store.New(db)
// A tenant with an application the owner can sign in to, and a live hosted-login
// session issued from it.
owner, err := st.UpsertUser(ctx, "local:owner@example.test", "owner@example.test", "Owner")
if err != nil {
t.Fatalf("upsert owner: %v", err)
}
tenant, err := st.CreateTenant(ctx, owner.ID, "acme", "Acme")
if err != nil {
t.Fatalf("create tenant: %v", err)
}
app, err := st.CreateApplication(ctx, owner.ID, store.Application{
TenantID: tenant.ID, Slug: "client", Name: "Client", AllowedHosts: []string{},
})
if err != nil {
t.Fatalf("create application: %v", err)
}
secretHash := []byte("hashed-client-secret")
if _, err := st.ConfigureClient(ctx, owner.ID, app.ID, []string{"https://app.example.test/cb"}, secretHash); err != nil {
t.Fatalf("configure client: %v", err)
}
ownerRole, err := st.SystemRoleID(ctx, "owner")
if err != nil {
t.Fatalf("owner role: %v", err)
}
if err := st.SetUserAccess(ctx, owner.ID, tenant.ID, owner.ID, ownerRole, []string{app.ID}); err != nil {
t.Fatalf("grant application access: %v", err)
}
codeHash := []byte("hashed-authorization-code")
if err := st.CreateAuthorizationCode(ctx, app.ID, owner.ID, "https://app.example.test/cb", codeHash); err != nil {
t.Fatalf("create authorization code: %v", err)
}
tokenHash := []byte("hashed-session-token")
if _, _, err := st.ExchangeAuthorizationCode(ctx, app.ClientID, "https://app.example.test/cb", secretHash, codeHash, tokenHash); err != nil {
t.Fatalf("exchange authorization code: %v", err)
}
before, err := st.CountActiveClientSessions(ctx, owner.ID)
if err != nil {
t.Fatalf("count sessions: %v", err)
}
if before != 1 {
t.Fatalf("active client sessions before logout = %d, want 1", before)
}
// A real session cookie, so the handler resolves an identity and takes the
// revocation path rather than the anonymous one.
secret := strings.Repeat("s", 32)
manager := session.New(secret, false)
server := &Server{
cfg: config.Config{BaseURL: "https://auth.example.test", AuthDomain: "auth.example.test"},
store: st,
sessions: manager,
// The handler logs how many sessions it revoked, so a discard logger is
// needed rather than leaving it nil.
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
issued := httptest.NewRecorder()
if err := manager.Set(issued, session.Claims{UserID: owner.ID, Management: true}); err != nil {
t.Fatalf("issue session: %v", err)
}
var sessionCookie *http.Cookie
for _, cookie := range issued.Result().Cookies() {
if cookie.Name == "go_loose_session" {
sessionCookie = cookie
}
}
if sessionCookie == nil {
t.Fatal("no session cookie was issued")
}
request := httptest.NewRequest(http.MethodPost, "https://auth.example.test/auth/logout", nil)
request.AddCookie(sessionCookie)
response := httptest.NewRecorder()
server.authLogout(response, request)
after, err := st.CountActiveClientSessions(ctx, owner.ID)
if err != nil {
t.Fatalf("count sessions: %v", err)
}
if after != 0 {
t.Fatalf("active client sessions after logout = %d, want 0: the other applications would still accept these tokens", after)
}
// The token itself must stop resolving, which is what the tenant application
// experiences as being signed out.
if _, err := st.ClientUserByToken(ctx, tokenHash); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("token still resolves after logout: %v, want ErrNotFound", err)
}
}
// testDatabaseURL returns a database for the store-backed tests to reset and
// migrate.
//
// It reads its own variable rather than GO_LOOSE_TEST_DATABASE_URL, and defaults to
// a different database again. Go runs package tests in parallel and both packages
// drop every table, so pointing them at one database makes them fight over the
// schema; deriving this from the shared variable would reproduce exactly that.
func testDatabaseURL(t *testing.T) string {
url := envOr("GO_LOOSE_TEST_SERVER_DATABASE_URL",
"postgres://goloose:goloose@localhost:5433/goloose_server_test?sslmode=disable")
requireScratchDatabase(t, url)
return url
}
// requireScratchDatabase refuses to run against anything that looks like a real
// database, because resetSchema drops every table in the public schema. The
// database name must contain "test" unless the operator opts out explicitly.
func requireScratchDatabase(t *testing.T, rawURL string) {
t.Helper()
if os.Getenv("GO_LOOSE_TEST_DATABASE_DESTRUCTIVE") == "1" {
return
}
parsed, err := neturl.Parse(rawURL)
if err != nil {
t.Fatalf("parse test database URL: %v", err)
}
name := strings.TrimPrefix(parsed.Path, "/")
if !strings.Contains(strings.ToLower(name), "test") {
t.Fatalf("refusing to run store-backed tests against database %q: resetSchema drops every table. "+
"Point it at a scratch database whose name contains \"test\" (run `make integration-db` to create one), "+
"or set GO_LOOSE_TEST_DATABASE_DESTRUCTIVE=1 to override.", name)
}
}
// envOr returns the environment value or a fallback.
func envOr(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
// resetSchema drops every table so a store-backed test starts from the same state a
// fresh installation has. The tables live in a scratch database; the same helper
// exists in the store package's own tests.
func resetSchema(ctx context.Context, db *sql.DB) error {
rows, err := db.QueryContext(ctx, `SELECT tablename FROM pg_tables WHERE schemaname = 'public'`)
if err != nil {
return err
}
var tables []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
rows.Close()
return err
}
tables = append(tables, name)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for _, name := range tables {
if _, err := db.ExecContext(ctx, `DROP TABLE IF EXISTS "`+name+`" CASCADE`); err != nil {
return err
}
}
return nil
}

View file

@ -27,10 +27,10 @@ type clientUserResponse struct {
Permissions []string `json:"permissions"`
}
func (s *Server) clientUser(user store.ClientUser) clientUserResponse {
func (s *Server) clientUser(r *http.Request, user store.ClientUser) clientUserResponse {
return clientUserResponse{
ID: user.ID, Email: user.Email, DisplayName: user.DisplayName,
AvatarURL: s.avatarURL(user.AvatarKey), TenantID: user.TenantID,
AvatarURL: s.avatarURL(r, user.AvatarKey), TenantID: user.TenantID,
TenantSlug: user.TenantSlug, ApplicationID: user.ApplicationID, Application: user.Application,
Role: user.Role, Permissions: user.Permissions,
}
@ -124,7 +124,7 @@ func (s *Server) clientToken(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{
"access_token": token, "token_type": "Bearer",
"expires_in": int(time.Until(expiresAt).Seconds()), "user": s.clientUser(user),
"expires_in": int(time.Until(expiresAt).Seconds()), "user": s.clientUser(r, user),
})
}
@ -143,7 +143,7 @@ func (s *Server) clientUserInfo(w http.ResponseWriter, r *http.Request) {
s.internalError(w, "load client user", err)
return
}
writeJSON(w, http.StatusOK, s.clientUser(user))
writeJSON(w, http.StatusOK, s.clientUser(r, user))
}
func (s *Server) clientLogout(w http.ResponseWriter, r *http.Request) {

View file

@ -12,8 +12,6 @@ import (
"github.com/danyel/go-loose/web"
)
const googleDemoClientID = "554628171917-forvpfs44pqajv77dfhl503prfqnhor3.apps.googleusercontent.com"
func (s *Server) installPage(w http.ResponseWriter, r *http.Request) {
if s.installed.Load() {
http.Redirect(w, r, "/login", http.StatusSeeOther)
@ -31,7 +29,6 @@ func (s *Server) installPage(w http.ResponseWriter, r *http.Request) {
}
page := strings.NewReplacer(
"{{CSRF_TOKEN}}", html.EscapeString(csrfToken),
"{{GOOGLE_CLIENT_ID}}", googleDemoClientID,
"{{CALLBACK_URL}}", html.EscapeString(s.cfg.OIDCRedirectURL),
).Replace(string(content))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@ -56,38 +53,44 @@ func (s *Server) install(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusForbidden, "invalid installer form; reload the installer")
return
}
clientID := strings.TrimSpace(r.Form.Get("oidc_client_id"))
clientSecret := strings.TrimSpace(r.Form.Get("oidc_client_secret"))
if clientID == "" || clientSecret == "" {
writeError(w, http.StatusBadRequest, "Google client ID and client secret are required")
return
}
if err := s.configureOIDC(r.Context(), "https://accounts.google.com", clientID, clientSecret); err != nil {
writeError(w, http.StatusBadGateway, "Google OIDC discovery failed")
return
}
ciphertext, err := secretbox.Encrypt(s.cfg.SessionSecret, clientSecret)
if err != nil {
s.internalError(w, "encrypt OIDC secret", err)
issuer := strings.TrimSpace(r.Form.Get("field1"))
clientID := strings.TrimSpace(r.Form.Get("field2"))
clientSecret := strings.TrimSpace(r.Form.Get("field3"))
redirectURL := strings.TrimSpace(r.Form.Get("field4"))
if issuer == "" || clientID == "" || clientSecret == "" || redirectURL == "" {
writeError(w, http.StatusBadRequest, "All four fields are required")
return
}
seedDemo := r.Form.Get("seed_demo") == "on"
var demoUsers []store.DemoUser
var err error
if seedDemo {
demoUsers, err = buildDemoUsers()
if err != nil {
s.internalError(w, "hash demo credentials", err)
return
}
} else {
demoUsers = nil
}
ciphertext, encErr := secretbox.Encrypt(s.cfg.SessionSecret, clientSecret)
if encErr != nil {
s.internalError(w, "encrypt OIDC secret", encErr)
return
}
err = s.store.Install(r.Context(), store.Installation{
OIDCIssuer: "https://accounts.google.com", OIDCClientID: clientID,
OIDCSecretCiphertext: ciphertext, SeededDemo: seedDemo,
OIDCIssuer: issuer, OIDCClientID: clientID,
OIDCSecretCiphertext: ciphertext, OIDCRedirectURL: redirectURL, SeededDemo: seedDemo,
}, demoUsers)
if err != nil {
s.internalError(w, "complete installation", err)
return
}
// Configure OIDC in the running server
if err := s.configureOIDC(r.Context(), issuer, clientID, clientSecret, redirectURL); err != nil {
s.internalError(w, "configure OIDC", err)
return
}
s.installed.Store(true)
if err := s.ensureLocalAdministrator(r.Context()); err != nil {
s.internalError(w, "bootstrap local administrator", err)

View file

@ -9,23 +9,51 @@ import (
"golang.org/x/oauth2"
)
func (s *Server) configureOIDC(ctx context.Context, issuer, clientID, clientSecret string) error {
func (s *Server) configureOIDC(ctx context.Context, issuer, clientID, clientSecret, redirectURL string) error {
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return fmt.Errorf("discover OIDC provider: %w", err)
}
config := &oauth2.Config{
ClientID: clientID, ClientSecret: clientSecret,
Endpoint: provider.Endpoint(), RedirectURL: s.cfg.OIDCRedirectURL,
Endpoint: provider.Endpoint(), RedirectURL: redirectURL,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
s.authMu.Lock()
s.oauth = config
s.verifier = provider.Verifier(&oidc.Config{ClientID: clientID})
// The logout endpoint is not part of the OAuth endpoint set, so it is read out
// of the discovery document separately. A provider that does not advertise one
// leaves this empty, which is what makes signing out fall back to clearing the
// local session alone.
s.endSession = providerLogoutEndpoint(provider)
s.authMu.Unlock()
return nil
}
// providerLogoutEndpoint reads end_session_endpoint out of an OIDC discovery
// document, returning an empty string when the provider does not advertise one.
func providerLogoutEndpoint(provider *oidc.Provider) string {
var claims struct {
EndSessionEndpoint string `json:"end_session_endpoint"`
}
if err := provider.Claims(&claims); err != nil {
return ""
}
return claims.EndSessionEndpoint
}
// logoutConfiguration returns what is needed to end a session at the identity
// provider: the discovered endpoint and the client it would be requested for.
func (s *Server) logoutConfiguration() (endpoint, clientID string) {
s.authMu.RLock()
defer s.authMu.RUnlock()
if s.oauth != nil {
clientID = s.oauth.ClientID
}
return s.endSession, clientID
}
func (s *Server) oidcConfiguration() (*oauth2.Config, *oidc.IDTokenVerifier) {
s.authMu.RLock()
defer s.authMu.RUnlock()

View file

@ -0,0 +1,91 @@
package server
import (
"errors"
"net/http"
"strings"
"github.com/danyel/go-loose/internal/store"
)
const maxPermissionLabel = 100
// listPermissions serves the whole catalog. Every management session may read it,
// because the role editor needs it to render its checkboxes.
func (s *Server) listPermissions(w http.ResponseWriter, r *http.Request) {
catalog, err := s.store.ListPermissions(r.Context())
if err != nil {
s.internalError(w, "list permissions", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"permissions": catalog})
}
// createPermission adds a name to the shared catalog so that roles can grant it.
//
// The catalog is installation-wide, so this is restricted to a system
// administrator or a holder of tenants.manage. Note what the new name does and
// does not do: it becomes grantable and is reported through /connect/userinfo,
// but nothing in the console checks for it, so it cannot unlock console
// behaviour. The response says so, and the UI repeats it.
func (s *Server) createPermission(w http.ResponseWriter, r *http.Request) {
var request struct {
Name string `json:"name"`
Label string `json:"label"`
Description string `json:"description"`
}
if !decodeJSON(w, r, &request) {
return
}
name := strings.TrimSpace(request.Name)
if !store.ValidPermissionName(name) {
writeError(w, http.StatusBadRequest,
"name must be lowercase words separated by dots, for example reports.export")
return
}
label := strings.TrimSpace(request.Label)
if label == "" || len(label) > maxPermissionLabel {
writeError(w, http.StatusBadRequest, "label must be between 1 and 100 characters")
return
}
description := strings.TrimSpace(request.Description)
if len(description) > 500 {
writeError(w, http.StatusBadRequest, "description must be 500 characters or fewer")
return
}
created, err := s.store.CreatePermission(
r.Context(), claimsFrom(r).UserID, name, label, description)
if err != nil {
switch {
case errors.Is(err, store.ErrPermissionTaken):
writeError(w, http.StatusConflict, "this permission already exists")
case errors.Is(err, store.ErrUnknownPermission):
writeError(w, http.StatusBadRequest, err.Error())
default:
s.handleStoreError(w, "create permission", err)
}
return
}
writeJSON(w, http.StatusCreated, created)
}
// deletePermission removes a catalog entry that no role grants.
func (s *Server) deletePermission(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if !store.ValidPermissionName(name) {
writeError(w, http.StatusBadRequest, "unknown permission name")
return
}
if err := s.store.DeletePermission(r.Context(), claimsFrom(r).UserID, name); err != nil {
switch {
case errors.Is(err, store.ErrPermissionInUse):
writeError(w, http.StatusConflict, "remove this permission from every role before deleting it")
case errors.Is(err, store.ErrPermissionSystem):
writeError(w, http.StatusConflict, "built-in permissions cannot be deleted")
default:
s.handleStoreError(w, "delete permission", err)
}
return
}
w.WriteHeader(http.StatusNoContent)
}

View file

@ -128,16 +128,36 @@ func (s *Server) avatar(w http.ResponseWriter, r *http.Request) {
func (s *Server) profileResponse(profile store.Profile) profileResponse {
return profileResponse{
ID: profile.ID, Email: profile.Email, DisplayName: profile.DisplayName,
AvatarURL: s.avatarURL(profile.AvatarKey), Memberships: profile.Memberships,
AvatarURL: s.avatarPath(profile.AvatarKey), Memberships: profile.Memberships,
IsSystemAdministrator: profile.IsSystemAdministrator, NameCustomized: profile.NameCustomized,
CreatedAt: profile.CreatedAt, LastLoginAt: profile.LastLoginAt,
ProfileUpdatedAt: profile.ProfileUpdatedAt, AvatarUpdatedAt: profile.AvatarUpdatedAt,
}
}
func (s *Server) avatarURL(key *string) string {
// avatarPath returns the same-origin path of a stored profile picture.
//
// The picture is served by this application, so a page of this application must
// reference it by a root-relative path. An absolute URL has to be built from
// cfg.BaseURL, and that value is routinely absent, stale, or aimed at a canonical
// address the browser is not using: it defaults to http://localhost:8080, which a
// browser asked to load it from any other origin refuses, and this application's
// own Content-Security-Policy allows images from 'self' only. A relative path is
// correct by construction in every deployment.
func (s *Server) avatarPath(key *string) string {
if key == nil || *key == "" {
return ""
}
return s.cfg.BaseURL + "/api/v1/avatars/" + *key
return "/api/v1/avatars/" + *key
}
// avatarURL is the absolute form, for the clients that read a picture from another
// origin: /connect/userinfo is consumed by applications such as go-tell, which
// cannot resolve a relative path against their own host.
func (s *Server) avatarURL(r *http.Request, key *string) string {
path := s.avatarPath(key)
if path == "" {
return ""
}
return s.absoluteURL(r, path)
}

View file

@ -9,7 +9,6 @@ import (
"image/png"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"testing"
@ -32,23 +31,44 @@ func profileRequest(t *testing.T, body string) *http.Request {
return request.WithContext(context.WithValue(request.Context(), claimsKey, claims))
}
func TestAvatarURLIsAbsoluteAndOmittedWithoutKey(t *testing.T) {
// The console reads its own picture through avatarPath, so it must be a relative
// path: the configured base URL is absent or stale often enough that building an
// absolute link from it yields an address the browser refuses to load.
func TestAvatarPathIsRelativeAndOmittedWithoutKey(t *testing.T) {
server := profileTestServer()
if got := server.avatarURL(nil); got != "" {
t.Fatalf("avatarURL(nil) = %q, want empty", got)
if got := server.avatarPath(nil); got != "" {
t.Fatalf("avatarPath(nil) = %q, want empty", got)
}
empty := ""
if got := server.avatarURL(&empty); got != "" {
t.Fatalf("avatarURL(empty) = %q, want empty", got)
if got := server.avatarPath(&empty); got != "" {
t.Fatalf("avatarPath(empty) = %q, want empty", got)
}
key := "k"
got := server.avatarURL(&key)
if want := "https://auth.example.test/api/v1/avatars/k"; got != want {
if got, want := server.avatarPath(&key), "/api/v1/avatars/k"; got != want {
t.Fatalf("avatarPath() = %q, want %q", got, want)
}
// A base URL left at its default must not leak into a page's markup.
server.cfg.BaseURL = "http://localhost:8080"
if got := server.avatarPath(&key); got != "/api/v1/avatars/k" {
t.Fatalf("avatarPath() with default base URL = %q", got)
}
}
// Another origin, such as go-tell reading /connect/userinfo, cannot resolve a
// relative path, so that form follows the request rather than the configuration.
func TestAvatarURLFollowsTheRequestNotTheConfiguredBase(t *testing.T) {
server := profileTestServer()
key := "k"
request := httptest.NewRequest(http.MethodGet, "https://nmbs.auth.example.test/connect/userinfo", nil)
if got, want := server.avatarURL(request, &key), "https://nmbs.auth.example.test/api/v1/avatars/k"; got != want {
t.Fatalf("avatarURL() = %q, want %q", got, want)
}
parsed, err := url.Parse(got)
if err != nil || parsed.Path != "/api/v1/avatars/k" {
t.Fatalf("avatar url = %q, err = %v", got, err)
if got := server.avatarURL(request, nil); got != "" {
t.Fatalf("avatarURL(nil) = %q, want empty", got)
}
// Without a host to follow, the configured base is the only thing left.
if got, want := server.avatarURL(&http.Request{}, &key), "https://auth.example.test/api/v1/avatars/k"; got != want {
t.Fatalf("avatarURL() without a request host = %q, want %q", got, want)
}
}
@ -58,7 +78,8 @@ func TestAvatarURLIsAbsoluteAndOmittedWithoutKey(t *testing.T) {
func TestClientUserExposesRoleCapabilitiesAndPicture(t *testing.T) {
server := profileTestServer()
key := "avatar-key"
payload := server.clientUser(store.ClientUser{
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/connect/userinfo", nil)
payload := server.clientUser(request, store.ClientUser{
ID: "user-1", Email: "operator@example.test", DisplayName: "Operator",
TenantSlug: "nmbs", Application: "guess", Role: "operator",
Permissions: []string{string(role.ManageKeys), string(role.EditProfile)},
@ -84,7 +105,7 @@ func TestClientUserExposesRoleCapabilitiesAndPicture(t *testing.T) {
// rather than an error.
func TestClientUserWithoutMembershipIsLeastPrivileged(t *testing.T) {
server := profileTestServer()
payload := server.clientUser(store.ClientUser{Role: "user", Permissions: []string{string(role.EditProfile)}})
payload := server.clientUser(nil, store.ClientUser{Role: "user", Permissions: []string{string(role.EditProfile)}})
if payload.AvatarURL != "" {
t.Fatalf("avatar url = %q, want empty", payload.AvatarURL)
}

View file

@ -103,8 +103,10 @@ func TestCreateRoleRejectsMalformedInputBeforeTouchingTheStore(t *testing.T) {
{"uppercase slug", `{"slug":"Release Manager","name":"Role","permissions":[]}`},
{"slug starting with a dash", `{"slug":"-role","name":"Role","permissions":[]}`},
{"empty name", `{"slug":"role","name":" ","permissions":[]}`},
{"unknown permission", `{"slug":"role","name":"Role","permissions":["superpower"]}`},
}
// Permission names are not checked here: the catalog is a table, so an unknown
// name can only be caught by the store, which reports it as a bad request.
// See TestARoleCannotGrantAPermissionThatIsNotInTheCatalog.
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
server := profileTestServer()

View file

@ -1,6 +1,7 @@
package server
import (
"bytes"
"context"
"database/sql"
"encoding/json"
@ -22,7 +23,6 @@ import (
"github.com/danyel/go-loose/internal/config"
"github.com/danyel/go-loose/internal/contract"
"github.com/danyel/go-loose/internal/key"
"github.com/danyel/go-loose/internal/role"
"github.com/danyel/go-loose/internal/secretbox"
"github.com/danyel/go-loose/internal/session"
"github.com/danyel/go-loose/internal/store"
@ -31,12 +31,16 @@ import (
)
type Server struct {
cfg config.Config
store *store.Store
db *sql.DB
sessions *session.Manager
oauth *oauth2.Config
verifier *oidc.IDTokenVerifier
cfg config.Config
store *store.Store
db *sql.DB
sessions *session.Manager
oauth *oauth2.Config
verifier *oidc.IDTokenVerifier
// endSession is the identity provider's logout endpoint, empty when the
// provider does not advertise one. It is what lets a sign-out here also end
// the provider session the other applications in the family share.
endSession string
authMu sync.RWMutex
installed atomic.Bool
httpClient *http.Client
@ -70,7 +74,7 @@ func New(ctx context.Context, cfg config.Config, db *sql.DB, logger *slog.Logger
if decryptErr != nil {
return nil, fmt.Errorf("decrypt installed OIDC secret: %w", decryptErr)
}
if err := server.configureOIDC(ctx, installation.OIDCIssuer, installation.OIDCClientID, secret); err != nil {
if err := server.configureOIDC(ctx, installation.OIDCIssuer, installation.OIDCClientID, secret, installation.OIDCRedirectURL); err != nil {
return nil, err
}
server.installed.Store(true)
@ -80,7 +84,7 @@ func New(ctx context.Context, cfg config.Config, db *sql.DB, logger *slog.Logger
} else if !errors.Is(err, store.ErrNotFound) {
return nil, fmt.Errorf("load installation: %w", err)
} else if cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" && cfg.OIDCClientSecret != "" {
if err := server.configureOIDC(ctx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.OIDCClientSecret); err != nil {
if err := server.configureOIDC(ctx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.OIDCClientSecret, cfg.OIDCRedirectURL); err != nil {
return nil, err
}
server.installed.Store(true)
@ -139,6 +143,9 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/v1/roles", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createRole))))
mux.Handle("PUT /api/v1/roles/{id}", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.updateRole))))
mux.Handle("DELETE /api/v1/roles/{id}", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.deleteRole))))
mux.Handle("GET /api/v1/permissions", s.requireSession(http.HandlerFunc(s.listPermissions)))
mux.Handle("POST /api/v1/permissions", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createPermission))))
mux.Handle("DELETE /api/v1/permissions/{name}", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.deletePermission))))
return s.recover(s.logRequests(s.securityHeaders(mux)))
}
@ -197,7 +204,8 @@ func (s *Server) openapi(w http.ResponseWriter, _ *http.Request) {
func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
name := path.Base(r.PathValue("name"))
if name != "style.css" && name != "app.js" && name != "profile.js" && name != "roles.js" {
if name != "style.css" && name != "app.js" && name != "profile.js" && name != "roles.js" &&
name != "account.js" {
http.NotFound(w, r)
return
}
@ -210,10 +218,62 @@ func (s *Server) serveEmbedded(w http.ResponseWriter, name string) {
http.Error(w, "asset unavailable", http.StatusInternalServerError)
return
}
body := content
// HTML pages get the theme origin substituted in. It is a placeholder rather than
// a literal because the origin is deployment configuration, not build-time.
//
// With no origin configured the two theme lines are removed outright rather than
// emitted with an empty href. An empty href is a same-origin request to this
// application, which 404s: a broken stylesheet request in the console, and a
// styling bug report that points at the wrong service entirely.
if path.Ext(name) == ".html" {
body = applyThemeOrigin(content, s.cfg.ThemeBaseURL)
}
w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(name)))
w.Write(content)
w.Write(body)
}
// themeOriginPlaceholder is substituted into every HTML page. It is deliberately not
// valid markup, so an unsubstituted page is obviously wrong rather than subtly so.
const themeOriginPlaceholder = "__GO_LOOSE_THEME_ORIGIN__"
// applyThemeOrigin substitutes the theme origin into an HTML page, or removes the
// theme lines entirely when none is configured.
func applyThemeOrigin(content []byte, base string) []byte {
// TrimSpace as well as the trailing slash: an environment variable set to spaces
// is a plausible accident, and it would otherwise produce a relative URL that
// resolves against this application and 404s.
origin := strings.TrimRight(strings.TrimSpace(base), "/")
if origin == "" {
return dropThemeLines(content)
}
return bytes.ReplaceAll(content, []byte(themeOriginPlaceholder), []byte(origin))
}
// dropThemeLines removes every line carrying a theme marker attribute. Markers are
// used rather than the placeholder itself so that the <link> and the <script> can both
// be removed: substituting an empty origin into the script URL would leave a module
// import of this application's own /rt/v1/components.js, which does not exist.
func dropThemeLines(content []byte) []byte {
lines := bytes.Split(content, []byte("\n"))
kept := make([][]byte, 0, len(lines))
for _, line := range lines {
if bytes.Contains(line, []byte(themeMarkerContract)) || bytes.Contains(line, []byte(themeMarkerScript)) {
continue
}
kept = append(kept, line)
}
return bytes.Join(kept, []byte("\n"))
}
// Marker attributes on the theme lines, used only to find and remove them.
const (
themeMarkerContract = "data-theme-contract"
themeMarkerScript = "data-bananas-components"
)
func (s *Server) authStart(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("return") != "" || s.requestTenantSlug(r.Host) != "" {
http.NotFound(w, r)
@ -342,12 +402,90 @@ func (s *Server) finishLogin(w http.ResponseWriter, r *http.Request, subject, em
http.Redirect(w, r, target, http.StatusSeeOther)
}
// authLogout ends the session and, where the identity provider supports it, the
// provider session the rest of the family shares.
//
// Three things happen, in this order, and the order matters:
//
// 1. Every hosted-login session the user holds is revoked. Go Loose issues those
// bearer tokens, so this is what actually signs the user out of the tenant
// applications: their tokens stop resolving the moment this runs, without the
// other application being told or having to ask. It happens before the local
// cookie is cleared, because the user id is only available while the session
// still exists.
// 2. The local management cookies are cleared.
// 3. The browser is sent to the identity provider's logout endpoint, so the single
// sign-on session that every application in the family relies on ends too. If
// the provider does not advertise an endpoint, this step is skipped and the user
// lands back on the sign-in page: the local sign-out still happened, and the
// other applications end at their next token check rather than immediately.
func (s *Server) authLogout(w http.ResponseWriter, r *http.Request) {
// Read before clearing. A missing or expired session is not a failure to sign
// out, so the claims are best effort and the sign-out proceeds regardless.
claims, err := s.sessions.Get(r)
if err == nil {
if revoked, revokeErr := s.store.RevokeUserClientSessions(r.Context(), claims.UserID); revokeErr != nil {
s.logger.Error("revoke client sessions on logout", "error", revokeErr)
} else if revoked > 0 {
s.logger.Info("revoked client sessions on logout", "count", revoked, "user", claims.UserID)
}
}
s.sessions.Clear(w)
s.sessions.ClearShared(w, s.cfg.AuthDomain)
if target := s.providerLogoutURL(r); target != "" {
http.Redirect(w, r, target, http.StatusSeeOther)
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// providerLogoutURL builds the identity provider logout URL, or an empty string when
// this installation has no provider endpoint to send the browser to.
//
// The client id is sent instead of an id_token_hint deliberately. A hint would be
// the precise way to end one particular provider session, but it means keeping a
// live credential in the browser cookie between login and logout, and the cookie has
// a size budget this codebase should not spend on it. Providers accept the client id
// and the post-logout redirect on their own.
func (s *Server) providerLogoutURL(r *http.Request) string {
endpoint, clientID := s.logoutConfiguration()
if endpoint == "" || clientID == "" {
return ""
}
target, err := url.Parse(endpoint)
if err != nil {
return ""
}
query := target.Query()
query.Set("client_id", clientID)
// Bounce back to the sign-in page so the provider has somewhere valid to send
// the browser. An unregistered redirect target is silently ignored by some
// providers, which would leave the user on the provider's own page.
query.Set("post_logout_redirect_uri", s.absoluteURL(r, "/login"))
target.RawQuery = query.Encode()
return target.String()
}
// absoluteURL resolves a local path against the request, honouring the address the
// client actually used so that a tenant host does not receive a link to the apex.
//
// The request wins over cfg.BaseURL. BaseURL is a configuration value that is
// routinely absent or stale -- it defaults to http://localhost:8080 -- so building
// a link from it hands the browser an address it cannot load, and a page served
// under a Content-Security-Policy of 'self' refuses it outright. The request's own
// host is the one address known to be both reachable and same-origin.
func (s *Server) absoluteURL(r *http.Request, path string) string {
if r != nil && r.Host != "" {
return s.requestScheme(r) + "://" + r.Host + path
}
if parsed, err := url.Parse(s.cfg.BaseURL); err == nil && parsed.Host != "" {
return parsed.Scheme + "://" + parsed.Host + path
}
return path
}
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
claims := claimsFrom(r)
allowed, err := s.store.HasConsoleAccess(r.Context(), claims.UserID)
@ -394,18 +532,23 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
s.internalError(w, "check system administrator", err)
return
}
catalog, err := s.store.ListPermissions(r.Context())
if err != nil {
s.internalError(w, "list permissions", err)
return
}
profile, err := s.store.Profile(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "load profile", err)
return
}
for index := range users {
users[index].AvatarURL = s.avatarURL(users[index].AvatarKey)
users[index].AvatarURL = s.avatarPath(users[index].AvatarKey)
}
writeJSON(w, http.StatusOK, map[string]any{
"user": claims, "profile": s.profileResponse(profile),
"tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts,
"users": users, "pending_users": pendingUsers, "permissions": role.Catalog(),
"users": users, "pending_users": pendingUsers, "permissions": catalog,
"is_system_administrator": isSystemAdministrator,
})
}
@ -431,6 +574,10 @@ func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
}
item, err := s.store.CreateTenant(r.Context(), claimsFrom(r).UserID, request.Slug, request.Name)
if err != nil {
if errors.Is(err, store.ErrTenantSlugTaken) {
writeError(w, http.StatusConflict, "a tenant with this slug already exists")
return
}
s.internalError(w, "create tenant", err)
return
}

View file

@ -0,0 +1,91 @@
package server
import (
"strings"
"testing"
)
// The theme origin is substituted into every HTML page. These tests cover the two
// branches and the failure mode in between, because a wrong result here is invisible
// until a user reports that the theme stopped applying.
const pageWithThemeLines = `<!doctype html>
<html data-theme="tokyo" data-mode="dark">
<head>
<link rel="stylesheet" href="/assets/style.css">
<link rel="stylesheet" href="__GO_LOOSE_THEME_ORIGIN__/rt/v1/contract.css" data-theme-contract>
</head>
<body>
<bananas-theme-selector id="theme"></bananas-theme-selector>
<script type="module" src="__GO_LOOSE_THEME_ORIGIN__/rt/v1/components.js" data-bananas-components></script>
</body>
</html>`
func TestThemeOriginIsSubstituted(t *testing.T) {
got := string(applyThemeOrigin([]byte(pageWithThemeLines), "https://themes.example.com"))
if strings.Contains(got, themeOriginPlaceholder) {
t.Error("the placeholder survived substitution")
}
if !strings.Contains(got, `href="https://themes.example.com/rt/v1/contract.css"`) {
t.Errorf("the contract stylesheet was not rewritten:\n%s", got)
}
if !strings.Contains(got, `src="https://themes.example.com/rt/v1/components.js"`) {
t.Errorf("the component module was not rewritten:\n%s", got)
}
// The application's own stylesheet must be untouched.
if !strings.Contains(got, `href="/assets/style.css"`) {
t.Error("the local stylesheet was rewritten")
}
}
func TestThemeOriginTrailingSlashIsTrimmed(t *testing.T) {
// A trailing slash would produce a double slash, and the service answers 404 for
// "//rt/v1/contract.css" because the route is matched on the clean path.
for _, base := range []string{
"https://themes.example.com/",
"https://themes.example.com///",
} {
got := string(applyThemeOrigin([]byte(pageWithThemeLines), base))
if strings.Contains(got, "com//") {
t.Errorf("base %q produced a doubled slash:\n%s", base, got)
}
}
}
func TestThemeLinesAreDroppedWhenNoOriginIsConfigured(t *testing.T) {
for _, base := range []string{"", " ", "/"} {
got := string(applyThemeOrigin([]byte(pageWithThemeLines), base))
if strings.Contains(got, themeOriginPlaceholder) {
t.Errorf("base %q left the placeholder in place:\n%s", base, got)
}
// An empty href would be a same-origin request to this application, which 404s
// and looks like a styling bug in the wrong service.
if strings.Contains(got, `href="/rt/v1/contract.css"`) {
t.Errorf("base %q emitted an empty contract href:\n%s", base, got)
}
if strings.Contains(got, themeMarkerContract) || strings.Contains(got, themeMarkerScript) {
t.Errorf("base %q left a theme line behind:\n%s", base, got)
}
// The rest of the page must survive.
if !strings.Contains(got, `href="/assets/style.css"`) {
t.Errorf("base %q removed the local stylesheet:\n%s", base, got)
}
if !strings.Contains(got, "<body>") || !strings.Contains(got, "</html>") {
t.Errorf("base %q damaged the page structure:\n%s", base, got)
}
}
}
func TestThemeOriginLeavesNonThemePagesAlone(t *testing.T) {
// A page with no theme lines must come back byte-identical, so that a deployment
// without the contract serving its login and install pages is a no-op.
plain := []byte("<!doctype html>\n<html>\n<body>no theme here</body>\n</html>\n")
if got := applyThemeOrigin(plain, "https://themes.example.com"); string(got) != string(plain) {
t.Errorf("a page without theme lines was modified:\n%s", got)
}
if got := applyThemeOrigin(plain, ""); string(got) != string(plain) {
t.Errorf("a page without theme lines was modified:\n%s", got)
}
}

View file

@ -89,7 +89,7 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
s.handleStoreError(w, "invite user", err)
return
}
user.AvatarURL = s.avatarURL(user.AvatarKey)
user.AvatarURL = s.avatarPath(user.AvatarKey)
writeJSON(w, http.StatusCreated, user)
}

View file

@ -108,14 +108,18 @@ func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email
return ManagedUser{}, err
}
defer tx.Rollback()
held, err := heldPermissions(ctx, tx, administratorID, tenantID)
permitted, err := hasPermission(ctx, tx, administratorID, tenantID, role.ManageUsers)
if err != nil {
return ManagedUser{}, err
}
if !within([]string{string(role.ManageUsers)}, held) {
if !permitted {
return ManagedUser{}, ErrNotFound
}
resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, held)
grantable, err := grantablePermissions(ctx, tx, administratorID, tenantID)
if err != nil {
return ManagedUser{}, err
}
resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, grantable)
if err != nil {
return ManagedUser{}, err
}
@ -161,14 +165,18 @@ func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, us
return err
}
defer tx.Rollback()
held, err := heldPermissions(ctx, tx, administratorID, tenantID)
permitted, err := hasPermission(ctx, tx, administratorID, tenantID, role.ManageUsers)
if err != nil {
return err
}
if !within([]string{string(role.ManageUsers)}, held) {
if !permitted {
return ErrNotFound
}
resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, held)
grantable, err := grantablePermissions(ctx, tx, administratorID, tenantID)
if err != nil {
return err
}
resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, grantable)
if err != nil {
return err
}
@ -410,3 +418,48 @@ func (s *Store) RevokeClientSession(ctx context.Context, tokenHash []byte) error
}
return nil
}
// RevokeUserClientSessions ends every hosted-login session belonging to a user and
// reports how many were live.
//
// Go Loose issues the bearer tokens that tenant applications authenticate with, so
// this is what makes signing out here sign you out there: the tokens stop resolving
// immediately, without the other application having to be told or even to ask. It is
// the part of single logout that is enforced here rather than at the identity
// provider.
//
// A user with no sessions is not an error, so the count is returned rather than
// ErrNotFound: logging out twice, or from a session that never used a client
// application, must still succeed.
func (s *Store) RevokeUserClientSessions(ctx context.Context, userID string) (int, error) {
if userID == "" {
return 0, nil
}
result, err := s.db.ExecContext(ctx, `
UPDATE client_user_sessions SET revoked_at = now()
WHERE user_id = $1 AND revoked_at IS NULL`, userID)
if err != nil {
return 0, err
}
count, err := result.RowsAffected()
if err != nil {
return 0, err
}
return int(count), nil
}
// CountActiveClientSessions reports how many hosted-login sessions a user holds.
// The account menu uses it to say whether signing out will reach other applications.
func (s *Store) CountActiveClientSessions(ctx context.Context, userID string) (int, error) {
if userID == "" {
return 0, nil
}
var count int
err := s.db.QueryRowContext(ctx, `
SELECT count(*) FROM client_user_sessions
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > now()`, userID).Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}

View file

@ -12,6 +12,7 @@ type Installation struct {
OIDCIssuer string
OIDCClientID string
OIDCSecretCiphertext []byte
OIDCRedirectURL string
SeededDemo bool
}
@ -25,9 +26,9 @@ type DemoUser struct {
func (s *Store) Installation(ctx context.Context) (Installation, error) {
var installation Installation
err := s.db.QueryRowContext(ctx, `
SELECT oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, seeded_demo
SELECT oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, oidc_redirect_url, seeded_demo
FROM installation_settings WHERE singleton = true`).
Scan(&installation.OIDCIssuer, &installation.OIDCClientID, &installation.OIDCSecretCiphertext, &installation.SeededDemo)
Scan(&installation.OIDCIssuer, &installation.OIDCClientID, &installation.OIDCSecretCiphertext, &installation.OIDCRedirectURL, &installation.SeededDemo)
if errors.Is(err, sql.ErrNoRows) {
return Installation{}, ErrNotFound
}
@ -41,9 +42,9 @@ func (s *Store) Install(ctx context.Context, installation Installation, demoUser
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `
INSERT INTO installation_settings(singleton, oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, seeded_demo)
VALUES (true, $1, $2, $3, $4)`,
installation.OIDCIssuer, installation.OIDCClientID, installation.OIDCSecretCiphertext, installation.SeededDemo); err != nil {
INSERT INTO installation_settings(singleton, oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, oidc_redirect_url, seeded_demo)
VALUES (true, $1, $2, $3, $4, $5)`,
installation.OIDCIssuer, installation.OIDCClientID, installation.OIDCSecretCiphertext, installation.OIDCRedirectURL, installation.SeededDemo); err != nil {
return err
}
if len(demoUsers) == 0 {
@ -118,14 +119,14 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role_id)
SELECT id, $1, r.id FROM tenants, roles r
SELECT t.id, $1, r.id FROM tenants t, roles r
WHERE r.slug = 'owner' AND r.tenant_id IS NULL
ON CONFLICT DO NOTHING`, userID); err != nil {
return false, err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO user_application_access(application_id, user_id, granted_by)
SELECT id, $1, $1 FROM applications ON CONFLICT DO NOTHING`, userID); err != nil {
SELECT a.id, $1, $1 FROM applications a ON CONFLICT DO NOTHING`, userID); err != nil {
return false, err
}
return true, tx.Commit()

View file

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"net/url"
"os"
"strings"
"testing"
@ -24,6 +25,7 @@ func integrationStore(t *testing.T) (*store.Store, *sql.DB, context.Context) {
if url == "" {
t.Skip("set GO_LOOSE_TEST_DATABASE_URL to run PostgreSQL integration tests")
}
requireScratchDatabase(t, url)
ctx := context.Background()
db, err := database.Open(ctx, url)
if err != nil {
@ -39,6 +41,27 @@ func integrationStore(t *testing.T) (*store.Store, *sql.DB, context.Context) {
return store.New(db), db, ctx
}
// requireScratchDatabase refuses to run against anything that looks like a real
// database. resetSchema drops every table in the public schema, so pointing
// these tests at a development database destroys it. The database name must
// contain "test" unless the operator opts out explicitly.
func requireScratchDatabase(t *testing.T, rawURL string) {
t.Helper()
if os.Getenv("GO_LOOSE_TEST_DATABASE_DESTRUCTIVE") == "1" {
return
}
parsed, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("parse GO_LOOSE_TEST_DATABASE_URL: %v", err)
}
name := strings.TrimPrefix(parsed.Path, "/")
if !strings.Contains(strings.ToLower(name), "test") {
t.Fatalf("refusing to run integration tests against database %q: resetSchema drops every table. "+
"Point GO_LOOSE_TEST_DATABASE_URL at a scratch database whose name contains \"test\" "+
"(run `make integration-db` to create one), or set GO_LOOSE_TEST_DATABASE_DESTRUCTIVE=1 to override.", name)
}
}
// grantApplicationAccess inserts an application grant directly so that tests can
// build states the management API intentionally refuses to create, such as a
// grant for a user with no membership.

View file

@ -6,6 +6,16 @@ import (
"errors"
)
// EnsurePasswordUser creates or updates the local account behind
// GO_LOOSE_LOCAL_ADMIN_PASSWORD and bootstraps it into the installation.
//
// The local administrator is operator-supplied rather than created by a visitor,
// so when the installation has no system administrator yet it claims the role.
// Without that, a database-only deployment would have nobody able to create
// tenants or approve waiting-room users, because both require the role that only
// a first OIDC sign-in otherwise grants. The claim is a no-op once any system
// administrator exists, so an installation already owned by a Google account is
// left alone.
func (s *Store) EnsurePasswordUser(ctx context.Context, email, displayName, passwordHash, tenantSlug, appSlug string) error {
user, err := s.UpsertUser(ctx, "local:"+email, email, displayName)
if err != nil {
@ -17,6 +27,9 @@ func (s *Store) EnsurePasswordUser(ctx context.Context, email, displayName, pass
WHERE id = $1 AND password_hash IS NULL`, user.ID, passwordHash); err != nil {
return err
}
if _, err := s.ClaimFirstSystemAdministrator(ctx, user.ID); err != nil {
return err
}
return s.Bootstrap(ctx, user.ID, tenantSlug, appSlug)
}

View file

@ -0,0 +1,224 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"slices"
"strings"
"github.com/danyel/go-loose/internal/role"
)
// Permission is one entry in the installation-wide catalog. The nine seeded by
// migration 0007 are the capabilities the console enforces; anything added later
// is grantable and reported to client applications but is not checked here,
// because nothing in the request path tests for it.
type Permission struct {
Name string `json:"name"`
Label string `json:"label"`
Description string `json:"description"`
System bool `json:"system"`
// RoleCount is how many roles currently grant it. A permission that no role
// grants can be removed again.
RoleCount int `json:"role_count"`
// Enforced reports whether the application itself checks for this
// permission. The console surfaces this so an operator is not misled into
// believing a custom capability is enforced.
Enforced bool `json:"enforced"`
}
var (
// ErrPermissionInUse is returned when a permission a role still grants is
// deleted.
ErrPermissionInUse = errors.New("permission is still granted by a role")
// ErrPermissionTaken is returned when a permission name already exists.
ErrPermissionTaken = errors.New("permission name is already in use")
// ErrPermissionSystem is returned when a built-in permission would be edited
// or deleted.
ErrPermissionSystem = errors.New("built-in permissions cannot be changed")
)
// permissionName constrains a name to lowercase dotted words such as
// "reports.export". The database enforces the same shape.
var permissionName = regexp.MustCompile(`^[a-z0-9][a-z0-9]*(?:[.-][a-z0-9]+)*$`)
// ValidPermissionName reports whether a name is well formed.
func ValidPermissionName(name string) bool {
return len(name) >= 3 && len(name) <= 100 && permissionName.MatchString(name)
}
// ListPermissions returns the whole catalog, most sensitive first for the
// built-ins and alphabetically after them.
func (s *Store) ListPermissions(ctx context.Context) ([]Permission, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT p.name, p.label, p.description, p.is_system,
(SELECT count(*) FROM role_permissions rp WHERE rp.permission = p.name)
FROM permissions p
ORDER BY array_position(`+permissionOrder+`, p.name) NULLS LAST, p.name`)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]Permission, 0)
for rows.Next() {
var item Permission
if err := rows.Scan(&item.Name, &item.Label, &item.Description, &item.System, &item.RoleCount); err != nil {
return nil, err
}
item.Enforced = role.Permission(item.Name).Valid()
result = append(result, item)
}
return result, rows.Err()
}
// CreatePermission adds a name to the catalog so that roles can grant it.
// Requires either a system administrator or tenants.manage, because the catalog
// is shared by every tenant rather than scoped to one.
func (s *Store) CreatePermission(ctx context.Context, actorID, name, label, description string) (Permission, error) {
name = strings.TrimSpace(name)
label = strings.TrimSpace(label)
description = strings.TrimSpace(description)
if !ValidPermissionName(name) {
return Permission{}, fmt.Errorf("%w: use lowercase words separated by dots", ErrUnknownPermission)
}
if label == "" || len(label) > 100 {
return Permission{}, fmt.Errorf("%w: label must be between 1 and 100 characters", ErrUnknownPermission)
}
if len(description) > 500 {
return Permission{}, fmt.Errorf("%w: description must be 500 characters or fewer", ErrUnknownPermission)
}
if role.Permission(name).Valid() {
// The name is one the application enforces; it is already seeded, so this
// can only be a duplicate.
return Permission{}, ErrPermissionTaken
}
permitted, err := s.mayAdministerCatalog(ctx, actorID)
if err != nil {
return Permission{}, err
}
if !permitted {
return Permission{}, ErrNotFound
}
item := Permission{Name: name, Label: label, Description: description, Enforced: false}
err = s.db.QueryRowContext(ctx, `
INSERT INTO permissions(name, label, description)
VALUES ($1, $2, $3)
RETURNING name, label, description, is_system,
(SELECT count(*) FROM role_permissions rp WHERE rp.permission = permissions.name)`,
name, label, description).
Scan(&item.Name, &item.Label, &item.Description, &item.System, &item.RoleCount)
if isUniqueViolation(err) {
return Permission{}, ErrPermissionTaken
}
if err != nil {
return Permission{}, err
}
return item, nil
}
// DeletePermission removes a catalog entry that no role grants. Built-in
// permissions are kept: the console enforces them and dropping one would leave a
// membership holding a capability nothing recognizes.
func (s *Store) DeletePermission(ctx context.Context, actorID, name string) error {
permitted, err := s.mayAdministerCatalog(ctx, actorID)
if err != nil {
return err
}
if !permitted {
return ErrNotFound
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var (
label string
system bool
grants int
)
err = tx.QueryRowContext(ctx, `
SELECT label, is_system,
(SELECT count(*) FROM role_permissions rp WHERE rp.permission = permissions.name)
FROM permissions WHERE name = $1 FOR UPDATE`, name).
Scan(&label, &system, &grants)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if system {
return ErrPermissionSystem
}
if grants > 0 {
return ErrPermissionInUse
}
if _, err := tx.ExecContext(ctx, `DELETE FROM permissions WHERE name = $1`, name); err != nil {
return err
}
return tx.Commit()
}
// mayAdministerCatalog reports whether the actor may change the shared catalog.
// The catalog is installation-wide, so this is a system administrator or an owner
// of some tenant, which is the permission that already means "administer the
// installation".
func (s *Store) mayAdministerCatalog(ctx context.Context, actorID string) (bool, error) {
return mayAdministerCatalog(ctx, s.db, actorID)
}
func mayAdministerCatalog(ctx context.Context, q querier, actorID string) (bool, error) {
var permitted bool
err := q.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM system_administrators WHERE user_id = $1)
OR `+permissionPredicate("scoped.tenant_id", "$1", "$2"),
actorID, string(role.ManageTenants)).Scan(&permitted)
return permitted, err
}
// grantablePermissions returns what an actor may hand to somebody: the
// permissions they hold, plus -- for whoever administers the shared catalog --
// every custom permission.
//
// The second half is what makes the catalog usable. A permission nobody holds
// yet could not otherwise be granted, because the escalation guard refuses to
// hand out a capability the granter does not have. Letting the author of a
// permission pass it on resolves that without weakening the guard for enforced
// permissions, which are never in the custom set.
func grantablePermissions(ctx context.Context, q querier, actorID, tenantID string) ([]string, error) {
held, err := heldPermissions(ctx, q, actorID, tenantID)
if err != nil {
return nil, err
}
administers, err := mayAdministerCatalog(ctx, q, actorID)
if err != nil || !administers {
return held, err
}
rows, err := q.QueryContext(ctx, `SELECT name FROM permissions WHERE NOT is_system`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
if !slices.Contains(held, name) {
held = append(held, name)
}
}
return held, rows.Err()
}
// permissionOrder lists the enforced capabilities so the catalog keeps the same
// most-sensitive-first order the role editor used before it was a table.
var permissionOrder = `ARRAY[
'tenants.manage', 'roles.manage', 'users.manage', 'applications.manage',
'keys.manage', 'contracts.manage', 'users.read', 'console.read', 'profile.edit'
]`

View file

@ -0,0 +1,224 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/danyel/go-loose/internal/role"
"github.com/danyel/go-loose/internal/store"
)
func TestCatalogSeedsTheEnforcedPermissions(t *testing.T) {
s, _, ctx := integrationStore(t)
catalog, err := s.ListPermissions(ctx)
if err != nil {
t.Fatalf("list permissions: %v", err)
}
if len(catalog) != len(role.Permissions()) {
t.Fatalf("catalog has %d entries, want the %d seeded permissions", len(catalog), len(role.Permissions()))
}
for _, item := range catalog {
if !item.System {
t.Errorf("%q was not marked built in", item.Name)
}
if !item.Enforced {
t.Errorf("%q is seeded but not reported as enforced", item.Name)
}
if item.Label == "" {
t.Errorf("%q has no label", item.Name)
}
}
// The order the role editor renders is most sensitive first.
if catalog[0].Name != string(role.ManageTenants) {
t.Errorf("catalog starts with %q, want %q", catalog[0].Name, role.ManageTenants)
}
}
func TestTenantCanAddAPermissionAndGrantIt(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs").ID
created, err := s.CreatePermission(ctx, owner, "reports.export", "Export reports", "Lets an app export reports.")
if err != nil {
t.Fatalf("create permission: %v", err)
}
if created.System {
t.Error("a permission added through the API must not be marked built in")
}
if created.Enforced {
t.Error("a custom permission cannot be enforced by the console")
}
// It is immediately grantable, exactly like a built-in one.
if _, err := s.CreateRole(ctx, owner, tenant, "reporter", "Reporter", "",
[]string{string(role.ReadConsole), "reports.export"}); err != nil {
t.Fatalf("grant a custom permission: %v", err)
}
catalog, err := s.ListPermissions(ctx)
if err != nil {
t.Fatalf("list permissions: %v", err)
}
found := false
for _, item := range catalog {
if item.Name == "reports.export" {
found = true
if item.RoleCount != 1 {
t.Errorf("role_count = %d, want 1", item.RoleCount)
}
}
}
if !found {
t.Fatal("the new permission is missing from the catalog")
}
}
func TestDeletingAPermissionInUseIsRefused(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs").ID
if _, err := s.CreatePermission(ctx, owner, "reports.export", "Export reports", ""); err != nil {
t.Fatalf("create permission: %v", err)
}
reporter, err := s.CreateRole(ctx, owner, tenant, "reporter", "Reporter", "",
[]string{string(role.ReadConsole), "reports.export"})
if err != nil {
t.Fatalf("create role: %v", err)
}
if err := s.DeletePermission(ctx, owner, "reports.export"); !errors.Is(err, store.ErrPermissionInUse) {
t.Fatalf("delete a granted permission = %v, want ErrPermissionInUse", err)
}
// Once no role grants it, the delete goes through.
if _, err := s.UpdateRole(ctx, owner, reporter.ID, "Reporter", "",
[]string{string(role.ReadConsole)}); err != nil {
t.Fatalf("drop the custom permission: %v", err)
}
if err := s.DeletePermission(ctx, owner, "reports.export"); err != nil {
t.Fatalf("delete an unused permission: %v", err)
}
}
func TestBuiltInPermissionsCannotBeDeleted(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
newTenant(t, s, ctx, owner, "nmbs")
if err := s.DeletePermission(ctx, owner, string(role.ManageUsers)); !errors.Is(err, store.ErrPermissionSystem) {
t.Fatalf("delete a built-in permission = %v, want ErrPermissionSystem", err)
}
}
func TestDuplicateAndMalformedPermissionsAreRejected(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
newTenant(t, s, ctx, owner, "nmbs")
if _, err := s.CreatePermission(ctx, owner, "reports.export", "Export", ""); err != nil {
t.Fatalf("create permission: %v", err)
}
if _, err := s.CreatePermission(ctx, owner, "reports.export", "Export again", ""); !errors.Is(err, store.ErrPermissionTaken) {
t.Fatalf("duplicate permission = %v, want ErrPermissionTaken", err)
}
// A name the console already enforces cannot be redefined.
if _, err := s.CreatePermission(ctx, owner, string(role.ManageUsers), "Manage", ""); !errors.Is(err, store.ErrPermissionTaken) {
t.Fatalf("redefining a built-in = %v, want ErrPermissionTaken", err)
}
for _, name := range []string{"Reports.Export", "reports export", ".reports", "reports.", "", "ab"} {
if _, err := s.CreatePermission(ctx, owner, name, "Label", ""); err == nil {
t.Errorf("CreatePermission(%q) was accepted", name)
}
}
}
// TestOnlyOwnersMayChangeTheSharedCatalog guards the fact that the catalog is
// installation-wide: a developer who may maintain their own tenant's members
// must not be able to add a capability every tenant can then grant.
func TestOnlyOwnersMayChangeTheSharedCatalog(t *testing.T) {
s, ctx, _, developer, _ := rolesFixture(t)
if _, err := s.CreatePermission(ctx, developer, "reports.export", "Export reports", ""); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer adding a permission = %v, want ErrNotFound", err)
}
if err := s.DeletePermission(ctx, developer, string(role.ReadConsole)); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer deleting a permission = %v, want ErrNotFound", err)
}
}
func TestARoleCannotGrantAPermissionThatIsNotInTheCatalog(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
_, err := s.CreateRole(ctx, owner, tenantID, "sneaky", "Sneaky", "",
[]string{string(role.ReadConsole), "not.in.the.catalog"})
if !errors.Is(err, store.ErrUnknownPermission) {
t.Fatalf("granting an unknown permission = %v, want ErrUnknownPermission", err)
}
// Nor can an existing role be edited to hold one.
if _, err := s.UpdateRole(ctx, owner, mustSystemRole(t, s, ctx, "viewer"), "Viewer", "",
[]string{string(role.ReadConsole), "not.in.the.catalog"}); !errors.Is(err, store.ErrUnknownPermission) {
t.Fatalf("editing in an unknown permission = %v, want ErrUnknownPermission", err)
}
}
// TestGrantingACustomPermissionNeedsTheRightAuthor documents the rule that makes
// the catalog usable without weakening the escalation guard. Whoever administers
// the catalog authored the permission, so they may pass it on even though nobody
// holds it yet; anybody else still needs to hold it first.
func TestGrantingACustomPermissionNeedsTheRightAuthor(t *testing.T) {
s, ctx, owner, developer, tenantID := rolesFixture(t)
if _, err := s.CreatePermission(ctx, owner, "reports.export", "Export reports", ""); err != nil {
t.Fatalf("create permission: %v", err)
}
// The owner administers the catalog, so they can define a role granting it.
// The role also carries users.manage so that whoever ends up holding it is
// actually able to assign roles to somebody else.
created, err := s.CreateRole(ctx, owner, tenantID, "reporter", "Reporter", "",
[]string{string(role.ReadConsole), string(role.ManageUsers), "reports.export"})
if err != nil {
t.Fatalf("owner granting a custom permission: %v", err)
}
// A developer administers members but not the catalog, and does not hold the
// permission, so they may not hand the role out.
member := addMember(t, s, ctx, owner, tenantID, "victim@example.test", "viewer")
if err := s.SetUserAccess(ctx, developer, tenantID, member, created.ID, nil); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer assigning a role carrying an unheld custom permission = %v, want ErrNotFound", err)
}
// Nor may they mint an equivalent role of their own.
if _, err := s.CreateRole(ctx, developer, tenantID, "rogue", "Rogue", "",
[]string{string(role.ReadConsole), string(role.ManageUsers), "reports.export"}); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer minting a custom permission = %v, want ErrNotFound", err)
}
// A member who does hold it, and may manage members, may pass it on.
reporter := addMember(t, s, ctx, owner, tenantID, "reporter@example.test", "viewer")
if err := s.SetUserAccess(ctx, owner, tenantID, reporter, created.ID, nil); err != nil {
t.Fatalf("owner assigning the role: %v", err)
}
if err := s.SetUserAccess(ctx, reporter, tenantID, member, created.ID, nil); err != nil {
t.Fatalf("holder passing the permission on: %v", err)
}
// Holding the permission is not enough on its own: a role without
// users.manage cannot assign anything.
readonly, err := s.CreateRole(ctx, owner, tenantID, "readonly-reporter", "Read only reporter", "",
[]string{string(role.ReadConsole), "reports.export"})
if err != nil {
t.Fatalf("create read-only role: %v", err)
}
if err := s.SetUserAccess(ctx, owner, tenantID, reporter, mustSystemRole(t, s, ctx, "viewer"), nil); err != nil {
t.Fatalf("move the reporter aside: %v", err)
}
if err := s.SetUserAccess(ctx, reporter, tenantID, member, readonly.ID, nil); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("assigning without users.manage = %v, want ErrNotFound", err)
}
}
func mustSystemRole(t *testing.T, s *store.Store, ctx context.Context, slug string) string {
t.Helper()
id, err := s.SystemRoleID(ctx, slug)
if err != nil {
t.Fatalf("look up %q: %v", slug, err)
}
return id
}

View file

@ -36,9 +36,9 @@ var (
ErrRoleInUse = errors.New("role is still assigned to members")
// ErrRoleSlugTaken is returned when a tenant already uses the requested slug.
ErrRoleSlugTaken = errors.New("role slug is already in use")
// ErrUnknownPermission is returned when a role is defined with a permission
// outside the vocabulary. It is a caller mistake rather than a server fault,
// so callers report it as a bad request.
// ErrUnknownPermission is returned when a role or catalog entry names a
// permission that is not in the catalog. It is a caller mistake rather than a
// server fault, so callers report it as a bad request.
ErrUnknownPermission = errors.New("unknown permission")
)
@ -143,25 +143,54 @@ func within(granted, held []string) bool {
return true
}
// normalizePermissions validates submitted permission names and returns them
// deduplicated in vocabulary order, so a role cannot be stored with an invented
// capability or an unstable order.
func normalizePermissions(permissions []string) ([]string, error) {
// normalizePermissions checks that every submitted name is in the catalog and
// returns them deduplicated in a stable order. Validation moved from the Go
// vocabulary to the database when permissions became rows, so a tenant-defined
// permission can be granted like any other while an invented one still cannot be
// stored.
func normalizePermissions(ctx context.Context, q querier, permissions []string) ([]string, error) {
requested := make(map[string]bool, len(permissions))
for _, value := range permissions {
parsed, ok := role.Parse(value)
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnknownPermission, value)
name := strings.TrimSpace(value)
if name == "" {
continue
}
requested[string(parsed)] = true
var known bool
if err := q.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM permissions WHERE name = $1)`, name).Scan(&known); err != nil {
return nil, err
}
if !known {
return nil, fmt.Errorf("%w: %q", ErrUnknownPermission, name)
}
requested[name] = true
}
rows, err := q.QueryContext(ctx, `
SELECT name FROM permissions
WHERE name = ANY($1)
ORDER BY array_position(`+permissionOrder+`, name) NULLS LAST, name`, keys(requested))
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]string, 0, len(requested))
for _, permission := range role.Permissions() {
if requested[string(permission)] {
result = append(result, string(permission))
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
result = append(result, name)
}
return result, nil
return result, rows.Err()
}
// keys returns the members of a set as a slice, for use as a SQL array parameter.
func keys(set map[string]bool) []string {
result := make([]string, 0, len(set))
for name := range set {
result = append(result, name)
}
return result
}
// roleGrants reads a role's permissions.
@ -280,21 +309,28 @@ func roleByID(ctx context.Context, q querier, roleID string) (Role, error) {
// CreateRole adds a tenant's own role. The caller must hold roles.manage and may
// not mint a role more powerful than one they already hold.
func (s *Store) CreateRole(ctx context.Context, actorID, tenantID, slug, name, description string, permissions []string) (Role, error) {
granted, err := normalizePermissions(permissions)
if err != nil {
return Role{}, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Role{}, err
}
defer tx.Rollback()
held, err := heldPermissions(ctx, tx, actorID, tenantID)
granted, err := normalizePermissions(ctx, tx, permissions)
if err != nil {
return Role{}, err
}
if !within([]string{string(role.ManageRoles)}, held) || !within(granted, held) {
permitted, err := hasPermission(ctx, tx, actorID, tenantID, role.ManageRoles)
if err != nil {
return Role{}, err
}
if !permitted {
return Role{}, ErrNotFound
}
grantable, err := grantablePermissions(ctx, tx, actorID, tenantID)
if err != nil {
return Role{}, err
}
if !within(granted, grantable) {
return Role{}, ErrNotFound
}
created, err := insertRole(ctx, tx, tenantID, slug, name, description)
@ -316,15 +352,15 @@ func (s *Store) CreateRole(ctx context.Context, actorID, tenantID, slug, name, d
// UpdateRole renames a role and replaces its permissions. Built-in roles are
// immutable and a tenant may only edit a role it owns.
func (s *Store) UpdateRole(ctx context.Context, actorID, roleID, name, description string, permissions []string) (Role, error) {
granted, err := normalizePermissions(permissions)
if err != nil {
return Role{}, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Role{}, err
}
defer tx.Rollback()
granted, err := normalizePermissions(ctx, tx, permissions)
if err != nil {
return Role{}, err
}
existing, err := lockRole(ctx, tx, roleID)
if err != nil {
@ -333,11 +369,18 @@ func (s *Store) UpdateRole(ctx context.Context, actorID, roleID, name, descripti
if existing.System || existing.TenantID == nil {
return Role{}, ErrNotFound
}
held, err := heldPermissions(ctx, tx, actorID, *existing.TenantID)
permitted, err := hasPermission(ctx, tx, actorID, *existing.TenantID, role.ManageRoles)
if err != nil {
return Role{}, err
}
if !within([]string{string(role.ManageRoles)}, held) || !within(granted, held) {
if !permitted {
return Role{}, ErrNotFound
}
grantable, err := grantablePermissions(ctx, tx, actorID, *existing.TenantID)
if err != nil {
return Role{}, err
}
if !within(granted, grantable) {
return Role{}, ErrNotFound
}
if _, err := tx.ExecContext(ctx, `
@ -379,11 +422,18 @@ func (s *Store) DeleteRole(ctx context.Context, actorID, roleID string) error {
if err != nil {
return err
}
held, err := heldPermissions(ctx, tx, actorID, *existing.TenantID)
permitted, err := hasPermission(ctx, tx, actorID, *existing.TenantID, role.ManageRoles)
if err != nil {
return err
}
if !within([]string{string(role.ManageRoles)}, held) || !within(permissions, held) {
if !permitted {
return ErrNotFound
}
grantable, err := grantablePermissions(ctx, tx, actorID, *existing.TenantID)
if err != nil {
return err
}
if !within(permissions, grantable) {
return ErrNotFound
}
if existing.MemberCount > 0 {

View file

@ -15,6 +15,9 @@ import (
var ErrNotFound = errors.New("not found")
// ErrTenantSlugTaken is returned when a tenant already uses the requested slug.
var ErrTenantSlugTaken = errors.New("tenant slug is already in use")
type Store struct {
db *sql.DB
}
@ -129,13 +132,21 @@ func (s *Store) Bootstrap(ctx context.Context, userID, tenantSlug, appSlug strin
if err != nil {
return err
}
// Ownership is granted only while the tenant still has none. The condition is
// about owners rather than about members: the installer's demonstration seed
// creates tenants that already hold administrators, and refusing ownership
// there would leave a local administrator bootstrapped afterwards with an
// account that can never reach the console. A tenant that already has an owner
// is still never taken over by a later bootstrap.
if _, err = tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role_id)
SELECT $1, $2, r.id
FROM roles r
WHERE r.slug = $3 AND r.tenant_id IS NULL
AND (NOT EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1)
OR EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2))
AND NOT EXISTS (
SELECT 1 FROM memberships m
JOIN roles held ON held.id = m.role_id
WHERE m.tenant_id = $1 AND held.slug = 'owner')
ON CONFLICT DO NOTHING`, tenantID, userID, "owner"); err != nil {
return err
}
@ -198,6 +209,11 @@ func (s *Store) CreateTenant(ctx context.Context, userID, slug, name string) (Te
var tenant Tenant
err = tx.QueryRowContext(ctx, `INSERT INTO tenants(slug, name) VALUES ($1, $2) RETURNING id, slug, name`, slug, name).
Scan(&tenant.ID, &tenant.Slug, &tenant.Name)
if isUniqueViolation(err) {
// Reporting this as a generic failure would surface a 500 for what is an
// ordinary mistake, and the console has no way to explain it.
return Tenant{}, ErrTenantSlugTaken
}
if err != nil {
return Tenant{}, err
}