go-loose/internal/store/installation.go

239 lines
8 KiB
Go
Raw Normal View History

2026-10-01 23:15:57 +02:00
package store
import (
"context"
"database/sql"
"errors"
2026-10-04 12:24:13 +02:00
"github.com/danyel/go-loose/internal/role"
2026-10-01 23:15:57 +02:00
)
type Installation struct {
OIDCIssuer string
OIDCClientID string
OIDCSecretCiphertext []byte
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.
2026-10-05 19:18:07 +02:00
OIDCRedirectURL string
2026-10-01 23:15:57 +02:00
SeededDemo bool
}
type DemoUser struct {
Email string
DisplayName string
PasswordHash string
TenantSlug string
}
func (s *Store) Installation(ctx context.Context) (Installation, error) {
var installation Installation
err := s.db.QueryRowContext(ctx, `
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.
2026-10-05 19:18:07 +02:00
SELECT oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, oidc_redirect_url, seeded_demo
2026-10-01 23:15:57 +02:00
FROM installation_settings WHERE singleton = true`).
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.
2026-10-05 19:18:07 +02:00
Scan(&installation.OIDCIssuer, &installation.OIDCClientID, &installation.OIDCSecretCiphertext, &installation.OIDCRedirectURL, &installation.SeededDemo)
2026-10-01 23:15:57 +02:00
if errors.Is(err, sql.ErrNoRows) {
return Installation{}, ErrNotFound
}
return installation, err
}
func (s *Store) Install(ctx context.Context, installation Installation, demoUsers []DemoUser) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `
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.
2026-10-05 19:18:07 +02:00
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 {
2026-10-01 23:15:57 +02:00
return err
}
if len(demoUsers) == 0 {
return tx.Commit()
}
for _, tenantSlug := range []string{"nmbs", "ypto"} {
if _, err := tx.ExecContext(ctx, `
INSERT INTO tenants(slug, name) VALUES ($1, upper($1))
ON CONFLICT (slug) DO NOTHING`, tenantSlug); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO applications(tenant_id, slug, name, description)
SELECT id, 'guess', 'Guess', 'Seeded Guess interview application'
FROM tenants WHERE slug = $1
ON CONFLICT (tenant_id, slug) DO NOTHING`, tenantSlug); err != nil {
return err
}
}
for _, demo := range demoUsers {
var userID string
if err := tx.QueryRowContext(ctx, `
INSERT INTO users(subject, email, display_name, password_hash, password_changed_at)
VALUES ('demo:' || lower($1), lower($1), $2, $3, now())
ON CONFLICT ((lower(email))) DO UPDATE SET
display_name = EXCLUDED.display_name,
password_hash = EXCLUDED.password_hash,
password_changed_at = now()
RETURNING id`, demo.Email, demo.DisplayName, demo.PasswordHash).Scan(&userID); err != nil {
return err
}
var tenantID string
if err := tx.QueryRowContext(ctx, `SELECT id FROM tenants WHERE slug = $1`, demo.TenantSlug).Scan(&tenantID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
2026-10-04 16:57:30 +02:00
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
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id`,
tenantID, userID, "admin"); err != nil {
2026-10-01 23:15:57 +02:00
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO user_application_access(application_id, user_id, granted_by)
SELECT id, $2, $2 FROM applications WHERE tenant_id = $1 AND slug = 'guess'
ON CONFLICT DO NOTHING`, tenantID, userID); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string) (bool, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return false, err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(554628171917)`); err != nil {
return false, err
}
var exists bool
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM system_administrators)`).Scan(&exists); err != nil {
return false, err
}
if exists {
return false, tx.Commit()
}
if _, err := tx.ExecContext(ctx, `INSERT INTO system_administrators(user_id) VALUES ($1)`, userID); err != nil {
return false, err
}
if _, err := tx.ExecContext(ctx, `
2026-10-04 16:57:30 +02:00
INSERT INTO memberships(tenant_id, user_id, role_id)
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.
2026-10-05 19:18:07 +02:00
SELECT t.id, $1, r.id FROM tenants t, roles r
2026-10-04 16:57:30 +02:00
WHERE r.slug = 'owner' AND r.tenant_id IS NULL
ON CONFLICT DO NOTHING`, userID); err != nil {
2026-10-01 23:15:57 +02:00
return false, err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO user_application_access(application_id, user_id, granted_by)
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.
2026-10-05 19:18:07 +02:00
SELECT a.id, $1, $1 FROM applications a ON CONFLICT DO NOTHING`, userID); err != nil {
2026-10-01 23:15:57 +02:00
return false, err
}
return true, tx.Commit()
}
2026-10-04 12:24:13 +02:00
// HasConsoleAccess reports whether the user may read the management console.
2026-10-04 16:57:30 +02:00
// System administrators always qualify; otherwise one of their memberships must
2026-10-04 12:24:13 +02:00
// grant console.read, which excludes self-service User members.
func (s *Store) HasConsoleAccess(ctx context.Context, userID string) (bool, error) {
2026-10-01 23:15:57 +02:00
var allowed bool
err := s.db.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM system_administrators WHERE user_id = $1)
2026-10-04 16:57:30 +02:00
OR `+permissionPredicate("scoped.tenant_id", "$1", "$2"),
userID, string(role.ReadConsole)).Scan(&allowed)
2026-10-04 12:24:13 +02:00
return allowed, err
}
// HasMembership reports whether the user belongs to any tenant. A member without
// console access is a self-service user rather than a waiting-room visitor.
func (s *Store) HasMembership(ctx context.Context, userID string) (bool, error) {
var allowed bool
err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM memberships WHERE user_id = $1)`, userID).
Scan(&allowed)
2026-10-01 23:15:57 +02:00
return allowed, err
}
func (s *Store) IsSystemAdministrator(ctx context.Context, userID string) (bool, error) {
var allowed bool
err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM system_administrators WHERE user_id = $1)`, userID).Scan(&allowed)
return allowed, err
}
func (s *Store) ListPendingUsers(ctx context.Context, administratorID string) ([]User, error) {
isAdministrator, err := s.IsSystemAdministrator(ctx, administratorID)
if err != nil {
return nil, err
}
if !isAdministrator {
return []User{}, nil
}
rows, err := s.db.QueryContext(ctx, `
SELECT u.id, u.email, u.display_name
FROM users u
WHERE NOT EXISTS (SELECT 1 FROM memberships m WHERE m.user_id = u.id)
AND NOT EXISTS (SELECT 1 FROM system_administrators sa WHERE sa.user_id = u.id)
ORDER BY u.created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]User, 0)
for rows.Next() {
var user User
if err := rows.Scan(&user.ID, &user.Email, &user.DisplayName); err != nil {
return nil, err
}
result = append(result, user)
}
return result, rows.Err()
}
2026-10-04 16:57:30 +02:00
// ApprovePendingUser gives a waiting-room visitor their first membership.
// Only a system administrator may do this, so the escalation guard that
// normally limits role assignment does not apply: the administrator is acting
// across tenants rather than inside one.
func (s *Store) ApprovePendingUser(ctx context.Context, administratorID, tenantID, userID, roleID string, applicationIDs []string) error {
2026-10-01 23:15:57 +02:00
isAdministrator, err := s.IsSystemAdministrator(ctx, administratorID)
if err != nil {
return err
}
if !isAdministrator {
return ErrNotFound
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
2026-10-04 16:57:30 +02:00
if _, err := resolveRoleID(ctx, tx, tenantID, roleID, role.Values()); err != nil {
return err
}
2026-10-01 23:15:57 +02:00
result, err := tx.ExecContext(ctx, `
2026-10-04 16:57:30 +02:00
INSERT INTO memberships(tenant_id, user_id, role_id)
2026-10-01 23:15:57 +02:00
SELECT $1, id, $3 FROM users WHERE id = $2
2026-10-04 16:57:30 +02:00
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id`,
tenantID, userID, roleID)
if err != nil {
return err
}
2026-10-01 23:15:57 +02:00
if err != nil {
return err
}
if count, _ := result.RowsAffected(); count == 0 {
return ErrNotFound
}
for _, applicationID := range applicationIDs {
result, err := tx.ExecContext(ctx, `
INSERT INTO user_application_access(application_id, user_id, granted_by)
SELECT id, $2, $3 FROM applications WHERE id = $1 AND tenant_id = $4
ON CONFLICT DO NOTHING`, applicationID, userID, administratorID, tenantID)
if err != nil {
return err
}
if count, _ := result.RowsAffected(); count == 0 {
return ErrNotFound
}
}
return tx.Commit()
}