initial commit
This commit is contained in:
commit
54576d9d33
61 changed files with 5569 additions and 0 deletions
299
internal/store/clientauth.go
Normal file
299
internal/store/clientauth.go
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ManagedUser struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
ApplicationIDs []string `json:"application_ids"`
|
||||
}
|
||||
|
||||
type ClientApplication struct {
|
||||
ID string
|
||||
TenantID string
|
||||
TenantSlug string
|
||||
TenantName string
|
||||
Application string
|
||||
Name string
|
||||
ClientID string
|
||||
}
|
||||
|
||||
type ClientUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
ApplicationID string `json:"application_id"`
|
||||
Application string `json:"application"`
|
||||
}
|
||||
|
||||
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,
|
||||
COALESCE((
|
||||
SELECT json_agg(ua.application_id ORDER BY ua.application_id)
|
||||
FROM user_application_access ua
|
||||
JOIN applications a ON a.id = ua.application_id
|
||||
WHERE ua.user_id = u.id AND a.tenant_id = m.tenant_id
|
||||
), '[]'::json)
|
||||
FROM users u
|
||||
JOIN memberships m ON m.user_id = u.id
|
||||
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')
|
||||
)
|
||||
ORDER BY u.email, m.tenant_id`, administratorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]ManagedUser, 0)
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(applicationIDs, &user.ApplicationIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, user)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, role, passwordHash string) (ManagedUser, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return ManagedUser{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var permitted bool
|
||||
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 {
|
||||
return ManagedUser{}, err
|
||||
}
|
||||
if !permitted {
|
||||
return ManagedUser{}, ErrNotFound
|
||||
}
|
||||
var user ManagedUser
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
INSERT INTO users(subject, email, display_name, password_hash, password_changed_at)
|
||||
VALUES ('invited:' || gen_random_uuid()::text, 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, email, display_name,
|
||||
CASE WHEN subject LIKE 'invited:%' THEN 'invited' ELSE 'active' END`,
|
||||
email, displayName, passwordHash).Scan(&user.ID, &user.Email, &user.DisplayName, &user.Status)
|
||||
if err != nil {
|
||||
return ManagedUser{}, err
|
||||
}
|
||||
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 {
|
||||
return ManagedUser{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ManagedUser{}, err
|
||||
}
|
||||
user.TenantID, user.Role, user.ApplicationIDs = tenantID, role, []string{}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, role string, applicationIDs []string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE memberships target SET role = $4
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count, _ := result.RowsAffected(); count == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM user_application_access ua
|
||||
USING applications a
|
||||
WHERE ua.application_id = a.id AND a.tenant_id = $1 AND ua.user_id = $2`,
|
||||
tenantID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
func (s *Store) ConfigureClient(ctx context.Context, administratorID, applicationID string, redirectURIs []string, secretHash []byte) (Application, error) {
|
||||
var app Application
|
||||
var encoded []byte
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
UPDATE applications a
|
||||
SET redirect_uris = $3, client_secret_hash = $4, 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')
|
||||
RETURNING a.id, a.client_id, to_json(a.redirect_uris), true`,
|
||||
applicationID, administratorID, redirectURIs, secretHash).
|
||||
Scan(&app.ID, &app.ClientID, &encoded, &app.ClientReady)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Application{}, ErrNotFound
|
||||
}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(encoded, &app.RedirectURIs)
|
||||
}
|
||||
return app, err
|
||||
}
|
||||
|
||||
func (s *Store) ClientForAuthorization(ctx context.Context, clientID, redirectURI, userID string) (ClientApplication, error) {
|
||||
var app ClientApplication
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT a.id, a.tenant_id, t.slug, t.name, a.slug, a.name, a.client_id
|
||||
FROM applications a
|
||||
JOIN tenants t ON t.id = a.tenant_id
|
||||
JOIN user_application_access ua ON ua.application_id = a.id AND ua.user_id = $3
|
||||
WHERE a.client_id = $1 AND $2 = ANY(a.redirect_uris)
|
||||
AND a.client_secret_hash IS NOT NULL`,
|
||||
clientID, redirectURI, userID).
|
||||
Scan(&app.ID, &app.TenantID, &app.TenantSlug, &app.TenantName, &app.Application, &app.Name, &app.ClientID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ClientApplication{}, ErrNotFound
|
||||
}
|
||||
return app, err
|
||||
}
|
||||
|
||||
func (s *Store) ValidateClientRedirect(ctx context.Context, clientID, redirectURI string) (ClientApplication, error) {
|
||||
var app ClientApplication
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT a.id, a.tenant_id, t.slug, t.name, a.slug, a.name, a.client_id
|
||||
FROM applications a
|
||||
JOIN tenants t ON t.id = a.tenant_id
|
||||
WHERE a.client_id = $1 AND $2 = ANY(a.redirect_uris)
|
||||
AND a.client_secret_hash IS NOT NULL`,
|
||||
clientID, redirectURI).
|
||||
Scan(&app.ID, &app.TenantID, &app.TenantSlug, &app.TenantName, &app.Application, &app.Name, &app.ClientID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ClientApplication{}, ErrNotFound
|
||||
}
|
||||
return app, err
|
||||
}
|
||||
|
||||
func (s *Store) ClientByID(ctx context.Context, clientID string) (ClientApplication, error) {
|
||||
var app ClientApplication
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT a.id, a.tenant_id, t.slug, t.name, a.slug, a.name, a.client_id
|
||||
FROM applications a
|
||||
JOIN tenants t ON t.id = a.tenant_id
|
||||
WHERE a.client_id = $1 AND a.client_secret_hash IS NOT NULL`,
|
||||
clientID).
|
||||
Scan(&app.ID, &app.TenantID, &app.TenantSlug, &app.TenantName, &app.Application, &app.Name, &app.ClientID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ClientApplication{}, ErrNotFound
|
||||
}
|
||||
return app, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateAuthorizationCode(ctx context.Context, applicationID, userID, redirectURI string, codeHash []byte) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO client_authorization_codes(code_hash, application_id, user_id, redirect_uri, expires_at)
|
||||
VALUES ($1, $2, $3, $4, now() + interval '2 minutes')`,
|
||||
codeHash, applicationID, userID, redirectURI)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ExchangeAuthorizationCode(ctx context.Context, clientID, redirectURI string, secretHash, codeHash, tokenHash []byte) (ClientUser, time.Time, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return ClientUser{}, time.Time{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var user ClientUser
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
UPDATE client_authorization_codes c SET used_at = now()
|
||||
FROM applications a, tenants t, users u
|
||||
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 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)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ClientUser{}, time.Time{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return ClientUser{}, time.Time{}, err
|
||||
}
|
||||
expiresAt := time.Now().UTC().Add(12 * time.Hour)
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO client_user_sessions(token_hash, application_id, user_id, expires_at)
|
||||
VALUES ($1, $2, $3, $4)`, tokenHash, user.ApplicationID, user.ID, expiresAt); err != nil {
|
||||
return ClientUser{}, time.Time{}, err
|
||||
}
|
||||
return user, expiresAt, tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) ClientUserByToken(ctx context.Context, tokenHash []byte) (ClientUser, error) {
|
||||
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
|
||||
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)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ClientUser{}, ErrNotFound
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
func (s *Store) RevokeClientSession(ctx context.Context, tokenHash []byte) error {
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
UPDATE client_user_sessions SET revoked_at = now()
|
||||
WHERE token_hash = $1 AND revoked_at IS NULL`, tokenHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count, _ := result.RowsAffected(); count == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
207
internal/store/installation.go
Normal file
207
internal/store/installation.go
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type Installation struct {
|
||||
OIDCIssuer string
|
||||
OIDCClientID string
|
||||
OIDCSecretCiphertext []byte
|
||||
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, seeded_demo
|
||||
FROM installation_settings WHERE singleton = true`).
|
||||
Scan(&installation.OIDCIssuer, &installation.OIDCClientID, &installation.OIDCSecretCiphertext, &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, seeded_demo)
|
||||
VALUES (true, $1, $2, $3, $4)`,
|
||||
installation.OIDCIssuer, installation.OIDCClientID, installation.OIDCSecretCiphertext, 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) VALUES ($1, $2, 'admin')
|
||||
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = 'admin'`, tenantID, userID); 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()
|
||||
}
|
||||
|
||||
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)
|
||||
SELECT id, $1, 'owner' FROM tenants ON CONFLICT DO NOTHING`, userID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_application_access(application_id, user_id, granted_by)
|
||||
SELECT id, $1, $1 FROM applications ON CONFLICT DO NOTHING`, userID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) HasManagementAccess(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)
|
||||
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()
|
||||
}
|
||||
|
||||
func (s *Store) ApprovePendingUser(ctx context.Context, administratorID, tenantID, userID, role 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()
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO memberships(tenant_id, user_id, role)
|
||||
SELECT $1, id, $3 FROM users WHERE id = $2
|
||||
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
|
||||
tenantID, userID, role)
|
||||
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()
|
||||
}
|
||||
43
internal/store/passwordauth.go
Normal file
43
internal/store/passwordauth.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func (s *Store) EnsurePasswordUser(ctx context.Context, email, displayName, passwordHash, tenantSlug, appSlug string) error {
|
||||
user, err := s.UpsertUser(ctx, "local:"+email, email, displayName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
SET password_hash = $2, password_changed_at = now()
|
||||
WHERE id = $1 AND password_hash IS NULL`, user.ID, passwordHash); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Bootstrap(ctx, user.ID, tenantSlug, appSlug)
|
||||
}
|
||||
|
||||
func (s *Store) PasswordUser(ctx context.Context, email string) (User, string, error) {
|
||||
var user User
|
||||
var passwordHash sql.NullString
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, email, display_name, password_hash
|
||||
FROM users
|
||||
WHERE lower(email) = lower($1)`, email).
|
||||
Scan(&user.ID, &user.Email, &user.DisplayName, &passwordHash)
|
||||
if errors.Is(err, sql.ErrNoRows) || (err == nil && !passwordHash.Valid) {
|
||||
return User{}, "", ErrNotFound
|
||||
}
|
||||
return user, passwordHash.String, err
|
||||
}
|
||||
|
||||
func (s *Store) RecordLogin(ctx context.Context, userID *string, email, remoteAddress string, successful bool, reason string) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO user_login_events(user_id, email, remote_address, successful, reason)
|
||||
VALUES ($1, lower($2), $3, $4, $5)`,
|
||||
userID, email, remoteAddress, successful, reason)
|
||||
return err
|
||||
}
|
||||
416
internal/store/store.go
Normal file
416
internal/store/store.go
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/danyel/go-loose/internal/contract"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
type Tenant struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type Application struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
TenantSlug string `json:"tenant_slug,omitempty"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
AllowedHosts []string `json:"allowed_hosts"`
|
||||
ClientID string `json:"client_id"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
ClientReady bool `json:"client_ready"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
ID string `json:"id"`
|
||||
ApplicationID string `json:"application_id"`
|
||||
Name string `json:"name"`
|
||||
Prefix string `json:"prefix"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Contract struct {
|
||||
ID string `json:"id"`
|
||||
ApplicationID string `json:"application_id"`
|
||||
Version string `json:"version"`
|
||||
SourceURL *string `json:"source_url,omitempty"`
|
||||
EndpointCount int `json:"endpoint_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Authorization struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
TenantSlug string `json:"tenant_slug,omitempty"`
|
||||
AppID string `json:"application_id,omitempty"`
|
||||
AppSlug string `json:"application_slug,omitempty"`
|
||||
APIKeyID string `json:"api_key_id,omitempty"`
|
||||
APIKeyName string `json:"api_key_name,omitempty"`
|
||||
}
|
||||
|
||||
func New(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
func (s *Store) UpsertUser(ctx context.Context, subject, email, displayName string) (User, error) {
|
||||
var user User
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
UPDATE users SET subject = $1, email = lower($2), display_name = $3, last_login_at = now()
|
||||
WHERE lower(email) = lower($2)
|
||||
RETURNING id, email, display_name`, subject, email, displayName).
|
||||
Scan(&user.ID, &user.Email, &user.DisplayName)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
INSERT INTO users(subject, email, display_name)
|
||||
VALUES ($1, lower($2), $3)
|
||||
RETURNING id, email, display_name`, subject, email, displayName).
|
||||
Scan(&user.ID, &user.Email, &user.DisplayName)
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) Bootstrap(ctx context.Context, userID, tenantSlug, appSlug string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var tenantID string
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
INSERT INTO tenants(slug, name) VALUES ($1, $2)
|
||||
ON CONFLICT (slug) DO UPDATE SET name = tenants.name
|
||||
RETURNING id`, tenantSlug, title(tenantSlug)).Scan(&tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO memberships(tenant_id, user_id, role)
|
||||
SELECT $1, $2, 'owner'
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO applications(tenant_id, slug, name, description)
|
||||
VALUES ($1, $2, $3, 'Bootstrap application')
|
||||
ON CONFLICT (tenant_id, slug) DO NOTHING`, tenantID, appSlug, title(appSlug)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO user_application_access(application_id, user_id, granted_by)
|
||||
SELECT a.id, $2, $2
|
||||
FROM applications a
|
||||
JOIN memberships m ON m.tenant_id = a.tenant_id AND m.user_id = $2
|
||||
WHERE a.tenant_id = $1 AND a.slug = $3
|
||||
ON CONFLICT DO NOTHING`, tenantID, userID, appSlug); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) ListTenants(ctx context.Context, userID string) ([]Tenant, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT t.id, t.slug, t.name, m.role
|
||||
FROM tenants t
|
||||
JOIN memberships m ON m.tenant_id = t.id
|
||||
WHERE m.user_id = $1
|
||||
ORDER BY t.name`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]Tenant, 0)
|
||||
for rows.Next() {
|
||||
var item Tenant
|
||||
if err := rows.Scan(&item.ID, &item.Slug, &item.Name, &item.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateTenant(ctx context.Context, userID, slug, name string) (Tenant, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var tenant Tenant
|
||||
err = tx.QueryRowContext(ctx, `INSERT INTO tenants(slug, name) VALUES ($1, $2) RETURNING id, slug, name`, slug, name).
|
||||
Scan(&tenant.ID, &tenant.Slug, &tenant.Name)
|
||||
if err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
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"
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
return tenant, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListApplications(ctx context.Context, userID string) ([]Application, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT a.id, a.tenant_id, t.slug, a.slug, a.name, a.description,
|
||||
to_json(a.allowed_hosts), a.client_id, to_json(a.redirect_uris),
|
||||
(a.client_secret_hash IS NOT NULL)
|
||||
FROM applications a
|
||||
JOIN tenants t ON t.id = a.tenant_id
|
||||
JOIN memberships m ON m.tenant_id = a.tenant_id
|
||||
WHERE m.user_id = $1
|
||||
ORDER BY t.slug, a.name`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]Application, 0)
|
||||
for rows.Next() {
|
||||
var item Application
|
||||
var allowedHosts, redirectURIs []byte
|
||||
if err := rows.Scan(&item.ID, &item.TenantID, &item.TenantSlug, &item.Slug, &item.Name, &item.Description, &allowedHosts, &item.ClientID, &redirectURIs, &item.ClientReady); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(allowedHosts, &item.AllowedHosts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(redirectURIs, &item.RedirectURIs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateApplication(ctx context.Context, userID string, app Application) (Application, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
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')
|
||||
)
|
||||
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)
|
||||
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) {
|
||||
return Application{}, ErrNotFound
|
||||
}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(allowedHosts, &app.AllowedHosts)
|
||||
}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(redirectURIs, &app.RedirectURIs)
|
||||
}
|
||||
return app, err
|
||||
}
|
||||
|
||||
func (s *Store) ListAPIKeys(ctx context.Context, userID string) ([]APIKey, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT k.id, k.application_id, k.name, k.prefix, k.status, k.expires_at, k.last_used_at, k.created_at
|
||||
FROM api_keys k
|
||||
JOIN memberships m ON m.tenant_id = k.tenant_id
|
||||
WHERE m.user_id = $1
|
||||
ORDER BY k.created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]APIKey, 0)
|
||||
for rows.Next() {
|
||||
var item APIKey
|
||||
if err := rows.Scan(&item.ID, &item.ApplicationID, &item.Name, &item.Prefix, &item.Status, &item.ExpiresAt, &item.LastUsedAt, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateAPIKey(ctx context.Context, userID, applicationID, name, prefix string, hash []byte, expiresAt *time.Time) (APIKey, error) {
|
||||
var item APIKey
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO api_keys(tenant_id, application_id, name, prefix, secret_hash, expires_at, created_by)
|
||||
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')
|
||||
RETURNING id, application_id, name, prefix, status, expires_at, last_used_at, created_at`,
|
||||
applicationID, name, prefix, hash, expiresAt, userID).
|
||||
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
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count, _ := result.RowsAffected()
|
||||
if count == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Authorize(ctx context.Context, hash []byte, tenantSlug, appSlug, method, path, host string) (Authorization, error) {
|
||||
var auth Authorization
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT t.id, t.slug, a.id, a.slug, k.id, k.name,
|
||||
(k.status = 'active' AND (k.expires_at IS NULL OR k.expires_at > now())
|
||||
AND ($2 = '' OR t.slug = $2) AND ($3 = '' OR a.slug = $3)
|
||||
AND (cardinality(a.allowed_hosts) = 0 OR $4 = ANY(a.allowed_hosts))) AS allowed
|
||||
FROM api_keys k
|
||||
JOIN applications a ON a.id = k.application_id
|
||||
JOIN tenants t ON t.id = k.tenant_id
|
||||
WHERE k.secret_hash = $1`,
|
||||
hash, tenantSlug, appSlug, host).
|
||||
Scan(&auth.TenantID, &auth.TenantSlug, &auth.AppID, &auth.AppSlug, &auth.APIKeyID, &auth.APIKeyName, &auth.Allowed)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
auth.Reason = "invalid_api_key"
|
||||
s.recordAuthorization(ctx, auth, method, path, host)
|
||||
return auth, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Authorization{}, err
|
||||
}
|
||||
if auth.Allowed {
|
||||
auth.Reason = "authorized"
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE api_keys SET last_used_at = now() WHERE id = $1`, auth.APIKeyID)
|
||||
} else {
|
||||
auth.Reason = "key_scope_mismatch_or_inactive"
|
||||
}
|
||||
s.recordAuthorization(ctx, auth, method, path, host)
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
func (s *Store) recordAuthorization(ctx context.Context, auth Authorization, method, path, host string) {
|
||||
_, _ = s.db.ExecContext(ctx, `
|
||||
INSERT INTO authorization_events(tenant_id, application_id, api_key_id, method, path, host, allowed, reason)
|
||||
VALUES (NULLIF($1, '')::uuid, NULLIF($2, '')::uuid, NULLIF($3, '')::uuid, $4, $5, $6, $7, $8)`,
|
||||
auth.TenantID, auth.AppID, auth.APIKeyID, method, path, host, auth.Allowed, auth.Reason)
|
||||
}
|
||||
|
||||
func (s *Store) SaveContract(ctx context.Context, userID, applicationID string, document contract.Document, sourceURL *string) (Contract, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Contract{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var result Contract
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
INSERT INTO openapi_contracts(application_id, version, source_url, document, created_by)
|
||||
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')
|
||||
RETURNING id, application_id, version, source_url, created_at`,
|
||||
applicationID, document.Version, sourceURL, document.JSON, userID).
|
||||
Scan(&result.ID, &result.ApplicationID, &result.Version, &result.SourceURL, &result.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Contract{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Contract{}, err
|
||||
}
|
||||
for _, endpoint := range document.Endpoints {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO contract_endpoints(contract_id, method, path, operation_id) VALUES ($1, $2, $3, $4)`,
|
||||
result.ID, endpoint.Method, endpoint.Path, endpoint.OperationID); err != nil {
|
||||
return Contract{}, err
|
||||
}
|
||||
}
|
||||
result.EndpointCount = len(document.Endpoints)
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Contract{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListContracts(ctx context.Context, userID string) ([]Contract, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT c.id, c.application_id, c.version, c.source_url, count(e.id), c.created_at
|
||||
FROM openapi_contracts c
|
||||
JOIN applications a ON a.id = c.application_id
|
||||
JOIN memberships m ON m.tenant_id = a.tenant_id
|
||||
LEFT JOIN contract_endpoints e ON e.contract_id = c.id
|
||||
WHERE m.user_id = $1
|
||||
GROUP BY c.id
|
||||
ORDER BY c.created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]Contract, 0)
|
||||
for rows.Next() {
|
||||
var item Contract
|
||||
if err := rows.Scan(&item.ID, &item.ApplicationID, &item.Version, &item.SourceURL, &item.EndpointCount, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func title(slug string) string {
|
||||
parts := strings.FieldsFunc(slug, func(r rune) bool { return r == '-' || r == '_' })
|
||||
for index := range parts {
|
||||
if parts[index] != "" {
|
||||
parts[index] = strings.ToUpper(parts[index][:1]) + parts[index][1:]
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func Wrap(operation string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue