fix installer

This commit is contained in:
Daniel Noulet 2026-10-04 12:24:13 +02:00
parent 318fb51085
commit 6319da650c
31 changed files with 2391 additions and 87 deletions

View file

@ -6,6 +6,8 @@ import (
"encoding/json"
"errors"
"time"
"github.com/danyel/go-loose/internal/role"
)
type ManagedUser struct {
@ -14,8 +16,13 @@ type ManagedUser struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
Status string `json:"status"`
ApplicationIDs []string `json:"application_ids"`
// AvatarKey is the stored locator. The server turns it into the absolute
// AvatarURL returned to clients.
AvatarKey *string `json:"-"`
AvatarURL string `json:"avatar_url"`
}
type ClientApplication struct {
@ -36,12 +43,21 @@ type ClientUser struct {
TenantSlug string `json:"tenant_slug"`
ApplicationID string `json:"application_id"`
Application string `json:"application"`
// Role is the membership role in the tenant owning the application. A user
// without a membership is reported as User so clients always see the least
// privileged role.
Role string `json:"role"`
Permissions []string `json:"permissions"`
// AvatarKey is the stored locator; the server expands it into AvatarURL.
AvatarKey *string `json:"-"`
AvatarURL string `json:"avatar_url"`
}
func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([]ManagedUser, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT u.id, m.tenant_id, u.email, u.display_name, m.role,
CASE WHEN u.subject LIKE 'invited:%' THEN 'invited' ELSE 'active' END,
u.avatar_key,
COALESCE((
SELECT json_agg(ua.application_id ORDER BY ua.application_id)
FROM user_application_access ua
@ -53,9 +69,9 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([
WHERE EXISTS (
SELECT 1 FROM memberships own
WHERE own.tenant_id = m.tenant_id AND own.user_id = $1
AND own.role IN ('owner', 'admin')
AND own.role = ANY($2)
)
ORDER BY u.email, m.tenant_id`, administratorID)
ORDER BY u.email, m.tenant_id`, administratorID, role.Grants(role.ManageUsers))
if err != nil {
return nil, err
}
@ -64,18 +80,20 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([
for rows.Next() {
var user ManagedUser
var applicationIDs []byte
if err := rows.Scan(&user.ID, &user.TenantID, &user.Email, &user.DisplayName, &user.Role, &user.Status, &applicationIDs); err != nil {
if err := rows.Scan(&user.ID, &user.TenantID, &user.Email, &user.DisplayName, &user.Role,
&user.Status, &user.AvatarKey, &applicationIDs); err != nil {
return nil, err
}
if err := json.Unmarshal(applicationIDs, &user.ApplicationIDs); err != nil {
return nil, err
}
user.Permissions = role.Role(user.Role).Permissions()
result = append(result, user)
}
return result, rows.Err()
}
func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, role, passwordHash string) (ManagedUser, error) {
func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, membershipRole, passwordHash string) (ManagedUser, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return ManagedUser{}, err
@ -85,8 +103,8 @@ func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email
if err := tx.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM memberships
WHERE tenant_id = $1 AND user_id = $2 AND role IN ('owner', 'admin')
)`, tenantID, administratorID).Scan(&permitted); err != nil {
WHERE tenant_id = $1 AND user_id = $2 AND role = ANY($3)
)`, tenantID, administratorID, role.Grants(role.ManageUsers)).Scan(&permitted); err != nil {
return ManagedUser{}, err
}
if !permitted {
@ -109,17 +127,18 @@ func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email
if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
tenantID, user.ID, role); err != nil {
tenantID, user.ID, membershipRole); err != nil {
return ManagedUser{}, err
}
if err := tx.Commit(); err != nil {
return ManagedUser{}, err
}
user.TenantID, user.Role, user.ApplicationIDs = tenantID, role, []string{}
user.TenantID, user.Role, user.ApplicationIDs = tenantID, membershipRole, []string{}
user.Permissions = role.Role(membershipRole).Permissions()
return user, nil
}
func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, role string, applicationIDs []string) error {
func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, membershipRole string, applicationIDs []string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
@ -130,8 +149,8 @@ func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, us
FROM memberships administrator
WHERE target.tenant_id = $1 AND target.user_id = $2
AND administrator.tenant_id = target.tenant_id
AND administrator.user_id = $3 AND administrator.role IN ('owner', 'admin')`,
tenantID, userID, administratorID, role)
AND administrator.user_id = $3 AND administrator.role = ANY($5)`,
tenantID, userID, administratorID, membershipRole, role.Grants(role.ManageUsers))
if err != nil {
return err
}
@ -165,12 +184,12 @@ func (s *Store) ConfigureClient(ctx context.Context, administratorID, applicatio
var encoded []byte
err := s.db.QueryRowContext(ctx, `
UPDATE applications a
SET redirect_uris = $3, client_secret_hash = $4, updated_at = now()
SET redirect_uris = $4, client_secret_hash = $5, updated_at = now()
FROM memberships m
WHERE a.id = $1 AND m.tenant_id = a.tenant_id AND m.user_id = $2
AND m.role IN ('owner', 'admin')
AND m.role = ANY($3)
RETURNING a.id, a.client_id, to_json(a.redirect_uris), true`,
applicationID, administratorID, redirectURIs, secretHash).
applicationID, administratorID, role.Grants(role.ManageApplications), redirectURIs, secretHash).
Scan(&app.ID, &app.ClientID, &encoded, &app.ClientReady)
if errors.Is(err, sql.ErrNoRows) {
return Application{}, ErrNotFound
@ -246,14 +265,20 @@ func (s *Store) ExchangeAuthorizationCode(ctx context.Context, clientID, redirec
var user ClientUser
err = tx.QueryRowContext(ctx, `
UPDATE client_authorization_codes c SET used_at = now()
FROM applications a, tenants t, users u
FROM applications a
JOIN tenants t ON t.id = a.tenant_id
JOIN user_application_access ua ON ua.application_id = a.id
JOIN users u ON u.id = ua.user_id
LEFT JOIN memberships m ON m.tenant_id = t.id AND m.user_id = u.id
WHERE c.code_hash = $1 AND c.used_at IS NULL AND c.expires_at > now()
AND c.redirect_uri = $2 AND a.id = c.application_id
AND ua.user_id = c.user_id AND ua.application_id = a.id
AND a.client_id = $3 AND a.client_secret_hash = $4
AND t.id = a.tenant_id AND u.id = c.user_id
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug`,
codeHash, redirectURI, clientID, secretHash).
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, &user.ApplicationID, &user.Application)
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug,
COALESCE(m.role, $5), u.avatar_key`,
codeHash, redirectURI, clientID, secretHash, role.User).
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug,
&user.ApplicationID, &user.Application, &user.Role, &user.AvatarKey)
if errors.Is(err, sql.ErrNoRows) {
return ClientUser{}, time.Time{}, ErrNotFound
}
@ -273,12 +298,18 @@ func (s *Store) ClientUserByToken(ctx context.Context, tokenHash []byte) (Client
var user ClientUser
err := s.db.QueryRowContext(ctx, `
UPDATE client_user_sessions s SET last_used_at = now()
FROM applications a, tenants t, users u, user_application_access ua
FROM applications a
JOIN tenants t ON t.id = a.tenant_id
JOIN user_application_access ua ON ua.application_id = a.id
JOIN users u ON u.id = ua.user_id
LEFT JOIN memberships m ON m.tenant_id = t.id AND m.user_id = u.id
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now()
AND a.id = s.application_id AND t.id = a.tenant_id AND u.id = s.user_id
AND ua.application_id = a.id AND ua.user_id = u.id
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug`,
tokenHash).Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, &user.ApplicationID, &user.Application)
AND a.id = s.application_id AND ua.user_id = s.user_id
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug,
COALESCE(m.role, $2), u.avatar_key`,
tokenHash, role.User).
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug,
&user.ApplicationID, &user.Application, &user.Role, &user.AvatarKey)
if errors.Is(err, sql.ErrNoRows) {
return ClientUser{}, ErrNotFound
}

View file

@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"errors"
"github.com/danyel/go-loose/internal/role"
)
type Installation struct {
@ -78,8 +80,9 @@ func (s *Store) Install(ctx context.Context, installation Installation, demoUser
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, 'admin')
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = 'admin'`, tenantID, userID); err != nil {
INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
tenantID, userID, role.Admin); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
@ -113,7 +116,7 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role)
SELECT id, $1, 'owner' FROM tenants ON CONFLICT DO NOTHING`, userID); err != nil {
SELECT id, $1, $2 FROM tenants ON CONFLICT DO NOTHING`, userID, role.Owner); err != nil {
return false, err
}
if _, err := tx.ExecContext(ctx, `
@ -124,11 +127,24 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string
return true, tx.Commit()
}
func (s *Store) HasManagementAccess(ctx context.Context, userID string) (bool, error) {
// HasConsoleAccess reports whether the user may read the management console.
// System administrators always qualify; otherwise the membership role 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 EXISTS(SELECT 1 FROM memberships WHERE user_id = $1)`, userID).Scan(&allowed)
OR EXISTS(SELECT 1 FROM memberships WHERE user_id = $1 AND role = ANY($2))`,
userID, role.Grants(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
}

View file

@ -0,0 +1,510 @@
package store_test
import (
"context"
"database/sql"
"errors"
"os"
"strings"
"testing"
"github.com/danyel/go-loose/internal/contract"
"github.com/danyel/go-loose/internal/database"
"github.com/danyel/go-loose/internal/role"
"github.com/danyel/go-loose/internal/store"
)
// integrationStore opens the disposable PostgreSQL database named by
// GO_LOOSE_TEST_DATABASE_URL, drops every table, and applies all migrations. The
// test is skipped when the variable is absent so unit runs never touch a
// developer's data.
func integrationStore(t *testing.T) (*store.Store, *sql.DB, context.Context) {
t.Helper()
url := os.Getenv("GO_LOOSE_TEST_DATABASE_URL")
if url == "" {
t.Skip("set GO_LOOSE_TEST_DATABASE_URL to run PostgreSQL integration tests")
}
ctx := context.Background()
db, err := database.Open(ctx, url)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { 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)
}
return store.New(db), db, ctx
}
// 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.
func grantApplicationAccess(t *testing.T, db *sql.DB, ctx context.Context, applicationID, userID string) {
t.Helper()
if _, err := db.ExecContext(ctx, `
INSERT INTO user_application_access(application_id, user_id, granted_by)
VALUES ($1, $2, $2)`, applicationID, userID); err != nil {
t.Fatalf("grant application access: %v", err)
}
}
func contractDocument() contract.Document {
return contract.Document{
Version: "3.1.0",
JSON: []byte(`{"openapi":"3.1.0","paths":{"/health":{"get":{"operationId":"health"}}}}`),
Endpoints: []contract.Endpoint{{Method: "GET", Path: "/health", OperationID: "health"}},
}
}
// resetSchema drops every table in the public schema, including
// schema_migrations, so that Migrate replays the full history from scratch.
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
}
defer rows.Close()
tables := make([]string, 0)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return err
}
tables = append(tables, `"`+name+`"`)
}
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
}
// newUser creates or reuses an account and returns its id.
func newUser(t *testing.T, s *store.Store, ctx context.Context, email string) string {
t.Helper()
user, err := s.UpsertUser(ctx, "test:"+email, email, strings.ToUpper(email[:1])+email[1:])
if err != nil {
t.Fatalf("upsert %s: %v", email, err)
}
return user.ID
}
// newTenant creates a tenant owned by owner and registers one application.
func newTenant(t *testing.T, s *store.Store, ctx context.Context, owner, slug string) store.Tenant {
t.Helper()
tenant, err := s.CreateTenant(ctx, owner, slug, strings.ToUpper(slug))
if err != nil {
t.Fatalf("create tenant %s: %v", slug, err)
}
return tenant
}
// addMember invites a member into the tenant with the given role.
func addMember(t *testing.T, s *store.Store, ctx context.Context, owner string, tenantID, email, membershipRole string) string {
t.Helper()
member, err := s.InviteUser(ctx, owner, tenantID, email, email, membershipRole, "not-a-real-hash")
if err != nil {
t.Fatalf("invite %s as %s: %v", email, membershipRole, err)
}
if member.Role != membershipRole {
t.Fatalf("invited role = %q, want %q", member.Role, membershipRole)
}
return member.ID
}
func TestMigrationsApplyAndRolesAreAccepted(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
for _, item := range []role.Role{role.Developer, role.Operator, role.User} {
addMember(t, s, ctx, owner, tenant.ID, string(item)+"@example.test", string(item))
}
if _, err := s.ListTenants(ctx, owner); err != nil {
t.Fatalf("list tenants: %v", err)
}
}
func TestMembershipRoleConstraintRejectsUnknownRole(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", string(role.Viewer))
if err := s.SetUserAccess(ctx, owner, tenant.ID, member, "superuser", nil); err == nil {
t.Fatal("the database must reject a role outside the permission matrix")
}
}
func TestPermissionMatrixIsEnforcedInQueries(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
application, err := s.CreateApplication(ctx, owner, store.Application{
TenantID: tenant.ID, Slug: "guess", Name: "Guess", AllowedHosts: []string{},
})
if err != nil {
t.Fatalf("owner cannot create an application: %v", err)
}
tests := []struct {
role role.Role
canCreateApp bool
canIssueKey bool
canSaveContract bool
canListManaged bool
canConfigureClien bool
}{
{role: role.Owner, canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true},
{role: role.Admin, canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true},
{role: role.Developer, canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: false, canConfigureClien: true},
{role: role.Operator, canCreateApp: false, canIssueKey: true, canSaveContract: true, canListManaged: false, canConfigureClien: false},
{role: role.Viewer, canCreateApp: false, canIssueKey: false, canSaveContract: false, canListManaged: false, canConfigureClien: false},
{role: role.User, canCreateApp: false, canIssueKey: false, canSaveContract: false, canListManaged: false, canConfigureClien: false},
}
for _, test := range tests {
t.Run(string(test.role), func(t *testing.T) {
member := addMember(t, s, ctx, owner, tenant.ID, string(test.role)+"@example.test", string(test.role))
slug := strings.ReplaceAll(string(test.role), "owner", "own")
_, err := s.CreateApplication(ctx, member, store.Application{
TenantID: tenant.ID, Slug: slug, Name: slug, AllowedHosts: []string{},
})
assertPermission(t, "create application", err, test.canCreateApp)
_, err = s.CreateAPIKey(ctx, member, application.ID, slug+"-key", "gl_prefix", secretHash(slug), nil)
assertPermission(t, "issue key", err, test.canIssueKey)
_, err = s.SaveContract(ctx, member, application.ID, contractDocument(), nil)
assertPermission(t, "import contract", err, test.canSaveContract)
managed, err := s.ListManagedUsers(ctx, member)
if err != nil {
t.Fatalf("list managed users: %v", err)
}
if test.canListManaged != (len(managed) > 0) {
t.Fatalf("users.read for %s: got %d members, want non-empty=%v", test.role, len(managed), test.canListManaged)
}
_, err = s.ConfigureClient(ctx, member, application.ID, []string{"https://app.example.test/callback"}, secretHash(slug))
assertPermission(t, "configure client login", err, test.canConfigureClien)
})
}
}
func TestConsoleAccessExcludesSelfServiceMembers(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
selfService := addMember(t, s, ctx, owner, tenant.ID, "enduser@example.test", string(role.User))
operator := addMember(t, s, ctx, owner, tenant.ID, "operator@example.test", string(role.Operator))
waiting := newUser(t, s, ctx, "waiting@example.test")
for _, test := range []struct {
name string
userID string
console bool
member bool
}{
{name: "owner", userID: owner, console: true, member: true},
{name: "operator", userID: operator, console: true, member: true},
{name: "self-service user", userID: selfService, console: false, member: true},
{name: "waiting room", userID: waiting, console: false, member: false},
} {
t.Run(test.name, func(t *testing.T) {
console, err := s.HasConsoleAccess(ctx, test.userID)
if err != nil {
t.Fatalf("HasConsoleAccess: %v", err)
}
if console != test.console {
t.Fatalf("HasConsoleAccess = %v, want %v", console, test.console)
}
member, err := s.HasMembership(ctx, test.userID)
if err != nil {
t.Fatalf("HasMembership: %v", err)
}
if member != test.member {
t.Fatalf("HasMembership = %v, want %v", member, test.member)
}
})
}
}
func TestTenantPermissionsComeFromTheRoleMatrix(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
developer := addMember(t, s, ctx, owner, tenant.ID, "dev@example.test", string(role.Developer))
tenants, err := s.ListTenants(ctx, developer)
if err != nil {
t.Fatalf("list tenants: %v", err)
}
if len(tenants) != 1 || tenants[0].Role != string(role.Developer) {
t.Fatalf("tenants = %+v", tenants)
}
if !contains(tenants[0].Permissions, string(role.ManageApplications)) {
t.Fatalf("developer permissions = %v", tenants[0].Permissions)
}
if contains(tenants[0].Permissions, string(role.ManageUsers)) {
t.Fatalf("developer permissions = %v, must not manage users", tenants[0].Permissions)
}
}
func TestProfileRoundTrip(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", string(role.Operator))
before, err := s.Profile(ctx, member)
if err != nil {
t.Fatalf("profile: %v", err)
}
if before.AvatarKey != nil || before.NameCustomized {
t.Fatalf("fresh profile = %+v", before)
}
if len(before.Memberships) != 1 || before.Memberships[0].Role != string(role.Operator) {
t.Fatalf("memberships = %+v", before.Memberships)
}
if !contains(before.Memberships[0].Permissions, string(role.ManageKeys)) {
t.Fatalf("membership permissions = %v", before.Memberships[0].Permissions)
}
name := "Renamed By User"
if err := s.UpdateProfile(ctx, member, store.ProfileUpdate{DisplayName: &name}); err != nil {
t.Fatalf("rename: %v", err)
}
after, err := s.Profile(ctx, member)
if err != nil {
t.Fatalf("profile after rename: %v", err)
}
if after.DisplayName != name || !after.NameCustomized || after.ProfileUpdatedAt == nil {
t.Fatalf("profile = %+v", after)
}
}
func TestLoginDoesNotOverwriteACustomizedDisplayName(t *testing.T) {
s, _, ctx := integrationStore(t)
user := newUser(t, s, ctx, "person@example.test")
name := "My Chosen Name"
if err := s.UpdateProfile(ctx, user, store.ProfileUpdate{DisplayName: &name}); err != nil {
t.Fatalf("rename: %v", err)
}
for range 2 {
if _, err := s.UpsertUser(ctx, "google:1234", "person@example.test", "Identity Provider Name"); err != nil {
t.Fatalf("upsert: %v", err)
}
}
profile, err := s.Profile(ctx, user)
if err != nil {
t.Fatalf("profile: %v", err)
}
if profile.DisplayName != name {
t.Fatalf("display name = %q, want %q", profile.DisplayName, name)
}
}
func TestUncustomizedDisplayNameStillFollowsTheIdentityProvider(t *testing.T) {
s, _, ctx := integrationStore(t)
user := newUser(t, s, ctx, "person@example.test")
if _, err := s.UpsertUser(ctx, "google:1234", "person@example.test", "Identity Provider Name"); err != nil {
t.Fatalf("upsert: %v", err)
}
profile, err := s.Profile(ctx, user)
if err != nil {
t.Fatalf("profile: %v", err)
}
if profile.DisplayName != "Identity Provider Name" {
t.Fatalf("display name = %q", profile.DisplayName)
}
}
func TestPictureIsStoredAndReadableByKey(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", string(role.Viewer))
key := "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl"
payload := []byte{0x89, 'P', 'N', 'G', 1, 2, 3, 4}
if err := s.UpdateProfile(ctx, member, store.ProfileUpdate{
AvatarKey: key, AvatarData: payload, ContentType: "image/png",
}); err != nil {
t.Fatalf("store picture: %v", err)
}
picture, err := s.Picture(ctx, key)
if err != nil {
t.Fatalf("load picture: %v", err)
}
if picture.ContentType != "image/png" || string(picture.Data) != string(payload) {
t.Fatalf("picture = %+v", picture)
}
if picture.UpdatedAt.IsZero() {
t.Fatal("picture must record when it was stored")
}
profile, err := s.Profile(ctx, member)
if err != nil {
t.Fatalf("profile: %v", err)
}
if profile.AvatarKey == nil || *profile.AvatarKey != key || profile.AvatarUpdatedAt == nil {
t.Fatalf("profile avatar = %+v", profile)
}
if err := s.UpdateProfile(ctx, member, store.ProfileUpdate{RemoveAvatar: true}); err != nil {
t.Fatalf("remove picture: %v", err)
}
if _, err := s.Picture(ctx, key); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("Picture() error = %v, want ErrNotFound", err)
}
cleared, err := s.Profile(ctx, member)
if err != nil {
t.Fatalf("profile: %v", err)
}
if cleared.AvatarKey != nil || cleared.AvatarUpdatedAt != nil {
t.Fatalf("profile still advertises a picture: %+v", cleared)
}
}
func TestReplacingAPictureRetiresTheOldKey(t *testing.T) {
s, _, ctx := integrationStore(t)
user := newUser(t, s, ctx, "person@example.test")
first := "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl"
second := "b3JkZXJ5a2V5Zm9ydGhlU2Vjb25kVXBvZGF0ZVRlc3QxMjM0NQ"
if err := s.UpdateProfile(ctx, user, store.ProfileUpdate{AvatarKey: first, AvatarData: []byte("a"), ContentType: "image/png"}); err != nil {
t.Fatalf("store first picture: %v", err)
}
if err := s.UpdateProfile(ctx, user, store.ProfileUpdate{AvatarKey: second, AvatarData: []byte("b"), ContentType: "image/png"}); err != nil {
t.Fatalf("store second picture: %v", err)
}
if _, err := s.Picture(ctx, first); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("the replaced key must stop resolving, got %v", err)
}
picture, err := s.Picture(ctx, second)
if err != nil || string(picture.Data) != "b" {
t.Fatalf("picture = %+v, err = %v", picture, err)
}
}
func TestPictureKeysAreUnique(t *testing.T) {
s, _, ctx := integrationStore(t)
first, second := newUser(t, s, ctx, "one@example.test"), newUser(t, s, ctx, "two@example.test")
key := "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl"
if err := s.UpdateProfile(ctx, first, store.ProfileUpdate{AvatarKey: key, AvatarData: []byte("a"), ContentType: "image/png"}); err != nil {
t.Fatalf("store first picture: %v", err)
}
if err := s.UpdateProfile(ctx, second, store.ProfileUpdate{AvatarKey: key, AvatarData: []byte("b"), ContentType: "image/png"}); err == nil {
t.Fatal("two users must not be able to claim the same picture key")
}
}
func TestIncompletePictureIsRejectedByTheDatabase(t *testing.T) {
s, _, ctx := integrationStore(t)
user := newUser(t, s, ctx, "person@example.test")
err := s.UpdateProfile(ctx, user, store.ProfileUpdate{AvatarKey: "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl"})
if err != nil {
t.Fatalf("an update without picture bytes is a no-op, got %v", err)
}
if _, err := s.Picture(ctx, "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("Picture() error = %v, want ErrNotFound", err)
}
}
func TestClientSessionReportsMembershipRoleAndPicture(t *testing.T) {
s, db, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
application, err := s.CreateApplication(ctx, owner, store.Application{
TenantID: tenant.ID, Slug: "guess", Name: "Guess", AllowedHosts: []string{},
})
if err != nil {
t.Fatalf("create application: %v", err)
}
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", string(role.Operator))
if _, err := s.ConfigureClient(ctx, owner, application.ID, []string{"https://app.example.test/callback"}, []byte("hash")); err != nil {
t.Fatalf("configure client: %v", err)
}
if err := s.UpdateProfile(ctx, member, store.ProfileUpdate{
AvatarKey: "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl",
AvatarData: []byte("a"), ContentType: "image/png",
}); err != nil {
t.Fatalf("store picture: %v", err)
}
grantApplicationAccess(t, db, ctx, application.ID, member)
if err := s.CreateAuthorizationCode(ctx, application.ID, member, "https://app.example.test/callback", []byte("code-hash")); err != nil {
t.Fatalf("create authorization code: %v", err)
}
user, _, err := s.ExchangeAuthorizationCode(ctx, application.ClientID, "https://app.example.test/callback", []byte("hash"), []byte("code-hash"), []byte("token-hash"))
if err != nil {
t.Fatalf("exchange authorization code: %v", err)
}
if user.Role != string(role.Operator) {
t.Fatalf("role = %q, want operator", user.Role)
}
if user.AvatarKey == nil {
t.Fatal("the client payload must expose the picture locator")
}
byToken, err := s.ClientUserByToken(ctx, []byte("token-hash"))
if err != nil {
t.Fatalf("client user by token: %v", err)
}
if byToken.Role != string(role.Operator) || byToken.AvatarKey == nil {
t.Fatalf("client user = %+v", byToken)
}
}
func TestClientSessionWithoutMembershipFallsBackToUserRole(t *testing.T) {
s, db, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
application, err := s.CreateApplication(ctx, owner, store.Application{
TenantID: tenant.ID, Slug: "guess", Name: "Guess", AllowedHosts: []string{},
})
if err != nil {
t.Fatalf("create application: %v", err)
}
orphan := newUser(t, s, ctx, "orphan@example.test")
if _, err := s.ConfigureClient(ctx, owner, application.ID, []string{"https://app.example.test/cb"}, []byte("hash")); err != nil {
t.Fatalf("configure client: %v", err)
}
grantApplicationAccess(t, db, ctx, application.ID, orphan)
if err := s.CreateAuthorizationCode(ctx, application.ID, orphan, "https://app.example.test/cb", []byte("code")); err != nil {
t.Fatalf("create authorization code: %v", err)
}
user, _, err := s.ExchangeAuthorizationCode(ctx, application.ClientID, "https://app.example.test/cb", []byte("hash"), []byte("code"), []byte("token"))
if err != nil {
t.Fatalf("exchange authorization code: %v", err)
}
if user.Role != string(role.User) {
t.Fatalf("role = %q, want the least privileged fallback %q", user.Role, role.User)
}
}
func assertPermission(t *testing.T, operation string, err error, allowed bool) {
t.Helper()
switch {
case allowed && err != nil:
t.Fatalf("%s must be allowed, got %v", operation, err)
case !allowed && !errors.Is(err, store.ErrNotFound):
t.Fatalf("%s must be denied with ErrNotFound, got %v", operation, err)
}
}
// secretHash builds a distinct digest per key name because secret_hash is unique.
func secretHash(name string) []byte {
return []byte("sha256-of-" + name)
}
func contains(values []string, want string) bool {
for _, item := range values {
if item == want {
return true
}
}
return false
}

150
internal/store/profile.go Normal file
View file

@ -0,0 +1,150 @@
package store
import (
"context"
"database/sql"
"errors"
"time"
"github.com/danyel/go-loose/internal/role"
)
// Membership is one tenant role held by a user.
type Membership struct {
TenantID string `json:"tenant_id"`
TenantSlug string `json:"tenant_slug"`
TenantName string `json:"tenant_name"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
}
// Profile is a user's own account information. AvatarKey is the stored locator;
// the server expands it into an absolute AvatarURL.
type Profile struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
AvatarKey *string `json:"-"`
AvatarURL string `json:"avatar_url"`
Memberships []Membership `json:"memberships"`
IsSystemAdministrator bool `json:"is_system_administrator"`
NameCustomized bool `json:"name_customized"`
CreatedAt time.Time `json:"created_at"`
LastLoginAt time.Time `json:"last_login_at"`
ProfileUpdatedAt *time.Time `json:"profile_updated_at,omitempty"`
AvatarUpdatedAt *time.Time `json:"avatar_updated_at,omitempty"`
}
// ProfileUpdate carries a self-service profile change. A nil DisplayName leaves
// the display name untouched. Avatar changes are mutually exclusive: Remove
// clears the picture, otherwise Data and ContentType replace it.
type ProfileUpdate struct {
DisplayName *string
RemoveAvatar bool
AvatarKey string
AvatarData []byte
ContentType string
}
// Picture is a stored profile picture.
type Picture struct {
Data []byte
ContentType string
UpdatedAt time.Time
}
// Profile loads a user together with every tenant role they hold.
func (s *Store) Profile(ctx context.Context, userID string) (Profile, error) {
var profile Profile
var avatarKey sql.NullString
err := s.db.QueryRowContext(ctx, `
SELECT u.id, u.email, u.display_name, u.avatar_key, u.name_customized,
u.created_at, u.last_login_at, u.profile_updated_at, u.avatar_updated_at,
EXISTS(SELECT 1 FROM system_administrators sa WHERE sa.user_id = u.id)
FROM users u
WHERE u.id = $1`, userID).
Scan(&profile.ID, &profile.Email, &profile.DisplayName, &avatarKey, &profile.NameCustomized,
&profile.CreatedAt, &profile.LastLoginAt, &profile.ProfileUpdatedAt,
&profile.AvatarUpdatedAt, &profile.IsSystemAdministrator)
if errors.Is(err, sql.ErrNoRows) {
return Profile{}, ErrNotFound
}
if err != nil {
return Profile{}, err
}
if avatarKey.Valid {
profile.AvatarKey = &avatarKey.String
}
profile.Memberships = []Membership{}
rows, err := s.db.QueryContext(ctx, `
SELECT m.tenant_id, t.slug, t.name, m.role
FROM memberships m
JOIN tenants t ON t.id = m.tenant_id
WHERE m.user_id = $1
ORDER BY t.name`, userID)
if err != nil {
return Profile{}, err
}
defer rows.Close()
for rows.Next() {
var item Membership
if err := rows.Scan(&item.TenantID, &item.TenantSlug, &item.TenantName, &item.Role); err != nil {
return Profile{}, err
}
item.Permissions = role.Role(item.Role).Permissions()
profile.Memberships = append(profile.Memberships, item)
}
return profile, rows.Err()
}
// UpdateProfile applies a self-service profile change. The user is identified by
// their own id, so no membership check is required: every account may edit its
// own display name and picture regardless of role.
func (s *Store) UpdateProfile(ctx context.Context, userID string, update ProfileUpdate) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if update.DisplayName != nil {
if _, err := tx.ExecContext(ctx, `
UPDATE users
SET display_name = $2, name_customized = true, profile_updated_at = now()
WHERE id = $1`, userID, *update.DisplayName); err != nil {
return err
}
}
switch {
case update.RemoveAvatar:
if _, err := tx.ExecContext(ctx, `
UPDATE users
SET avatar = NULL, avatar_key = NULL, avatar_content_type = NULL,
avatar_updated_at = NULL, profile_updated_at = now()
WHERE id = $1`, userID); err != nil {
return err
}
case len(update.AvatarData) > 0:
if _, err := tx.ExecContext(ctx, `
UPDATE users
SET avatar = $2, avatar_key = $3, avatar_content_type = $4,
avatar_updated_at = now(), profile_updated_at = now()
WHERE id = $1`, userID, update.AvatarData, update.AvatarKey, update.ContentType); err != nil {
return err
}
}
return tx.Commit()
}
// Picture loads a stored profile picture by its unguessable key.
func (s *Store) Picture(ctx context.Context, key string) (Picture, error) {
var picture Picture
err := s.db.QueryRowContext(ctx, `
SELECT avatar, avatar_content_type, avatar_updated_at
FROM users
WHERE avatar_key = $1 AND avatar IS NOT NULL`, key).
Scan(&picture.Data, &picture.ContentType, &picture.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Picture{}, ErrNotFound
}
return picture, err
}

View file

@ -10,6 +10,7 @@ import (
"time"
"github.com/danyel/go-loose/internal/contract"
"github.com/danyel/go-loose/internal/role"
)
var ErrNotFound = errors.New("not found")
@ -29,6 +30,9 @@ type Tenant struct {
Slug string `json:"slug"`
Name string `json:"name"`
Role string `json:"role"`
// Permissions is derived from Role so that the console and API clients read
// capabilities instead of re-implementing the role matrix.
Permissions []string `json:"permissions"`
}
type Application struct {
@ -86,8 +90,15 @@ func (s *Store) UpsertUser(ctx context.Context, subject, email, displayName stri
return User{}, err
}
defer tx.Rollback()
// The identity provider owns the display name until the user customizes it
// in their profile. The row must still match on every login, otherwise the
// insert below would collide with the unique email index.
err = tx.QueryRowContext(ctx, `
UPDATE users SET subject = $1, email = lower($2), display_name = $3, last_login_at = now()
UPDATE users
SET subject = $1,
email = lower($2),
display_name = CASE WHEN name_customized THEN display_name ELSE $3 END,
last_login_at = now()
WHERE lower(email) = lower($2)
RETURNING id, email, display_name`, subject, email, displayName).
Scan(&user.ID, &user.Email, &user.DisplayName)
@ -120,10 +131,10 @@ func (s *Store) Bootstrap(ctx context.Context, userID, tenantSlug, appSlug strin
}
if _, err = tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role)
SELECT $1, $2, 'owner'
SELECT $1, $2, $3
WHERE NOT EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1)
OR EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2)
ON CONFLICT DO NOTHING`, tenantID, userID); err != nil {
ON CONFLICT DO NOTHING`, tenantID, userID, role.Owner); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `
@ -161,6 +172,7 @@ func (s *Store) ListTenants(ctx context.Context, userID string) ([]Tenant, error
if err := rows.Scan(&item.ID, &item.Slug, &item.Name, &item.Role); err != nil {
return nil, err
}
item.Permissions = role.Role(item.Role).Permissions()
result = append(result, item)
}
return result, rows.Err()
@ -181,7 +193,8 @@ func (s *Store) CreateTenant(ctx context.Context, userID, slug, name string) (Te
if _, err := tx.ExecContext(ctx, `INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, 'owner')`, tenant.ID, userID); err != nil {
return Tenant{}, err
}
tenant.Role = "owner"
tenant.Role = string(role.Owner)
tenant.Permissions = role.Owner.Permissions()
if err := tx.Commit(); err != nil {
return Tenant{}, err
}
@ -225,11 +238,13 @@ func (s *Store) CreateApplication(ctx context.Context, userID string, app Applic
INSERT INTO applications(tenant_id, slug, name, description, allowed_hosts)
SELECT $1, $2, $3, $4, $5
WHERE EXISTS (
SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $6 AND role IN ('owner', 'admin')
SELECT 1 FROM memberships
WHERE tenant_id = $1 AND user_id = $6 AND role = ANY($7)
)
RETURNING id, tenant_id, slug, name, description, to_json(allowed_hosts),
client_id, to_json(redirect_uris), false`,
app.TenantID, app.Slug, app.Name, app.Description, app.AllowedHosts, userID)
app.TenantID, app.Slug, app.Name, app.Description, app.AllowedHosts, userID,
role.Grants(role.ManageApplications))
var allowedHosts, redirectURIs []byte
err := row.Scan(&app.ID, &app.TenantID, &app.Slug, &app.Name, &app.Description, &allowedHosts, &app.ClientID, &redirectURIs, &app.ClientReady)
if errors.Is(err, sql.ErrNoRows) {
@ -273,9 +288,9 @@ func (s *Store) CreateAPIKey(ctx context.Context, userID, applicationID, name, p
SELECT a.tenant_id, a.id, $2, $3, $4, $5, $6
FROM applications a
JOIN memberships m ON m.tenant_id = a.tenant_id
WHERE a.id = $1 AND m.user_id = $6 AND m.role IN ('owner', 'admin')
WHERE a.id = $1 AND m.user_id = $6 AND m.role = ANY($7)
RETURNING id, application_id, name, prefix, status, expires_at, last_used_at, created_at`,
applicationID, name, prefix, hash, expiresAt, userID).
applicationID, name, prefix, hash, expiresAt, userID, role.Grants(role.ManageKeys)).
Scan(&item.ID, &item.ApplicationID, &item.Name, &item.Prefix, &item.Status, &item.ExpiresAt, &item.LastUsedAt, &item.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return APIKey{}, ErrNotFound
@ -287,8 +302,8 @@ func (s *Store) RevokeAPIKey(ctx context.Context, userID, keyID string) error {
result, err := s.db.ExecContext(ctx, `
UPDATE api_keys k SET status = 'revoked', revoked_at = now()
FROM memberships m
WHERE k.id = $1 AND m.tenant_id = k.tenant_id AND m.user_id = $2 AND m.role IN ('owner', 'admin')`,
keyID, userID)
WHERE k.id = $1 AND m.tenant_id = k.tenant_id AND m.user_id = $2 AND m.role = ANY($3)`,
keyID, userID, role.Grants(role.ManageKeys))
if err != nil {
return err
}
@ -349,9 +364,10 @@ func (s *Store) SaveContract(ctx context.Context, userID, applicationID string,
SELECT a.id, $2, $3, $4, $5
FROM applications a
JOIN memberships m ON m.tenant_id = a.tenant_id
WHERE a.id = $1 AND m.user_id = $5 AND m.role IN ('owner', 'admin')
WHERE a.id = $1 AND m.user_id = $5 AND m.role = ANY($6)
RETURNING id, application_id, version, source_url, created_at`,
applicationID, document.Version, sourceURL, document.JSON, userID).
applicationID, document.Version, sourceURL, document.JSON, userID,
role.Grants(role.ManageContracts)).
Scan(&result.ID, &result.ApplicationID, &result.Version, &result.SourceURL, &result.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Contract{}, ErrNotFound