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:
parent
3fdc8cb489
commit
c7c81fb65c
43 changed files with 2248 additions and 298 deletions
|
|
@ -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),
|
||||
|
|
|
|||
47
internal/database/migrations/0007_permission_catalog.sql
Normal file
47
internal/database/migrations/0007_permission_catalog.sql
Normal 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;
|
||||
5
internal/database/migrations/0008_oidc_redirect_url.sql
Normal file
5
internal/database/migrations/0008_oidc_redirect_url.sql
Normal 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;
|
||||
366
internal/server/account_test.go
Normal file
366
internal/server/account_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
91
internal/server/permissions.go
Normal file
91
internal/server/permissions.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
91
internal/server/theme_test.go
Normal file
91
internal/server/theme_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
224
internal/store/permissions.go
Normal file
224
internal/store/permissions.go
Normal 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'
|
||||
]`
|
||||
224
internal/store/permissions_integration_test.go
Normal file
224
internal/store/permissions_integration_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue