Claiming the system administrator role no longer grants access to every application in the installation. Owning a tenant is what lets an administrator administer it; entering the applications running inside it is a separate, explicit grant that can be withdrawn again. Handing it out at claim time gave every installation a standing administrator account inside every customer application, which is the opposite of the boundary this product draws. The behaviour suite now covers single sign-on between applications directly: a second application on the same tenant must receive a code and identify the same person with no sign-in page in between, once Go Loose already holds a session. Also adds theme-token tests against the shared styling contract and store coverage for application grants, and drops the redundant server and security scheme declarations from the published specification.
248 lines
8.8 KiB
Go
248 lines
8.8 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
|
|
"github.com/danyel/go-loose/internal/role"
|
|
)
|
|
|
|
type Installation struct {
|
|
OIDCIssuer string
|
|
OIDCClientID string
|
|
OIDCSecretCiphertext []byte
|
|
OIDCRedirectURL string
|
|
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, `
|
|
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.OIDCRedirectURL, &installation.SeededDemo)
|
|
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, `
|
|
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 {
|
|
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, `
|
|
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 {
|
|
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()
|
|
}
|
|
|
|
// ClaimFirstSystemAdministrator promotes the first user to sign in to system
|
|
// administrator and gives them ownership of every tenant that exists at that
|
|
// moment. It is a no-op once an administrator exists.
|
|
//
|
|
// It grants console reach across the installation and nothing more: no
|
|
// application grant, so the new administrator cannot sign in to a tenant
|
|
// application until one is deliberately given to them.
|
|
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, `
|
|
INSERT INTO memberships(tenant_id, user_id, role_id)
|
|
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
|
|
}
|
|
// Deliberately no application grant. Owning every tenant is what lets the
|
|
// administrator administer it; it is not a licence to enter the applications
|
|
// running inside it. Access to a client application is only ever an explicit
|
|
// grant, so an administrator who wants to use one attaches themselves the same
|
|
// way anybody else does, visibly, from the console, and it can be withdrawn
|
|
// again without touching the tenant. Granting it here instead would hand every
|
|
// installation a standing administrator account inside every customer
|
|
// application, which is the opposite of the boundary this product draws.
|
|
return true, tx.Commit()
|
|
}
|
|
|
|
// HasConsoleAccess reports whether the user may read the management console.
|
|
// System administrators always qualify; otherwise one of their memberships must
|
|
// grant console.read, which excludes self-service User members.
|
|
func (s *Store) HasConsoleAccess(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)
|
|
OR `+permissionPredicate("scoped.tenant_id", "$1", "$2"),
|
|
userID, string(role.ReadConsole)).Scan(&allowed)
|
|
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)
|
|
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()
|
|
}
|
|
|
|
// 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 {
|
|
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()
|
|
if _, err := resolveRoleID(ctx, tx, tenantID, roleID, role.Values()); err != nil {
|
|
return err
|
|
}
|
|
result, err := tx.ExecContext(ctx, `
|
|
INSERT INTO memberships(tenant_id, user_id, role_id)
|
|
SELECT $1, id, $3 FROM users WHERE id = $2
|
|
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id`,
|
|
tenantID, userID, roleID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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()
|
|
}
|