fix installer
This commit is contained in:
parent
318fb51085
commit
6319da650c
31 changed files with 2391 additions and 87 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
510
internal/store/integration_test.go
Normal file
510
internal/store/integration_test.go
Normal 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
150
internal/store/profile.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue