go-loose/internal/store/clientauth.go

466 lines
17 KiB
Go
Raw Permalink Normal View History

2026-10-01 23:15:57 +02:00
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
2026-10-04 12:24:13 +02:00
"github.com/danyel/go-loose/internal/role"
2026-10-01 23:15:57 +02:00
)
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"`
2026-10-04 16:57:30 +02:00
RoleID string `json:"role_id"`
2026-10-04 12:24:13 +02:00
Permissions []string `json:"permissions"`
2026-10-01 23:15:57 +02:00
Status string `json:"status"`
ApplicationIDs []string `json:"application_ids"`
2026-10-04 12:24:13 +02:00
// AvatarKey is the stored locator. The server turns it into the absolute
// AvatarURL returned to clients.
AvatarKey *string `json:"-"`
AvatarURL string `json:"avatar_url"`
2026-10-01 23:15:57 +02:00
}
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"`
2026-10-04 12:24:13 +02:00
// 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.
2026-10-04 16:57:30 +02:00
Role string `json:"role"`
// Permissions is read from the role's own rows. A user without a membership
// gets the permissions of the built-in User role.
2026-10-04 12:24:13 +02:00
Permissions []string `json:"permissions"`
// AvatarKey is the stored locator; the server expands it into AvatarURL.
AvatarKey *string `json:"-"`
AvatarURL string `json:"avatar_url"`
2026-10-01 23:15:57 +02:00
}
func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([]ManagedUser, error) {
rows, err := s.db.QueryContext(ctx, `
2026-10-04 16:57:30 +02:00
SELECT u.id, m.tenant_id, u.email, u.display_name, r.slug, m.role_id,
2026-10-01 23:15:57 +02:00
CASE WHEN u.subject LIKE 'invited:%' THEN 'invited' ELSE 'active' END,
2026-10-04 12:24:13 +02:00
u.avatar_key,
2026-10-04 16:57:30 +02:00
COALESCE((
SELECT json_agg(rp.permission ORDER BY rp.permission)
FROM role_permissions rp WHERE rp.role_id = m.role_id
), '[]'::json),
2026-10-01 23:15:57 +02:00
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
2026-10-04 16:57:30 +02:00
JOIN roles r ON r.id = m.role_id
WHERE `+permissionPredicate("m.tenant_id", "$1", "$2")+`
ORDER BY u.email, m.tenant_id`, administratorID, string(role.ManageUsers))
2026-10-01 23:15:57 +02:00
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]ManagedUser, 0)
for rows.Next() {
var user ManagedUser
2026-10-04 16:57:30 +02:00
var permissions, applicationIDs []byte
2026-10-04 12:24:13 +02:00
if err := rows.Scan(&user.ID, &user.TenantID, &user.Email, &user.DisplayName, &user.Role,
2026-10-04 16:57:30 +02:00
&user.RoleID, &user.Status, &user.AvatarKey, &permissions, &applicationIDs); err != nil {
return nil, err
}
if err := json.Unmarshal(permissions, &user.Permissions); err != nil {
2026-10-01 23:15:57 +02:00
return nil, err
}
if err := json.Unmarshal(applicationIDs, &user.ApplicationIDs); err != nil {
return nil, err
}
result = append(result, user)
}
return result, rows.Err()
}
2026-10-04 16:57:30 +02:00
// InviteUser adds a member to a tenant or, when the email already exists,
// re-invites them. The caller must hold users.manage in that tenant and may only
// hand out a role whose permissions they hold themselves.
func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, roleID string, passwordHash string) (ManagedUser, error) {
2026-10-01 23:15:57 +02:00
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return ManagedUser{}, err
}
defer tx.Rollback()
feat: account menu, database roles, and single logout Bundles the console work from the current branch: the header account control, database-backed roles and permissions, and signing out across the applications that share this sign-in. The account control replaces a separate identity chip and a separate sign-out button, which meant the header carried two controls describing one fact. web/account.js is mounted into every page by a placeholder and owns the keyboard handling, the outside-click behaviour and the sign-out form once. It also fills the trigger's name, which the previous markup left empty. Signing out revokes the hosted-login bearer sessions this installation issued, then redirects to the provider's end_session_endpoint when the discovery document advertises one. Applications that authenticate against this installation therefore lose their session too; one pointed straight at an identity provider is out of its reach and is unchanged. Roles and permissions become rows rather than a closed enum, so a tenant can extend the catalog without a migration, and a role can no longer be granted a capability that is not in it. requireSameOrigin now compares against the origin the request arrived on instead of the configured base URL, which defaulted to http://localhost:8080 and so rejected every write in a deployment that never set it. Two bugs found while verifying in a browser: - A profile picture never rendered. avatarURL built an absolute URL from the configured base URL, and the page's own img-src 'self' blocked it. The console's own pages now take a root-relative path; only /connect/userinfo, read from another origin, needs an absolute one, and it follows the request. - absoluteURL preferred the configuration over the request while its comment claimed the opposite, which also misdirected the logout redirect. Card layout: the footer was one two-column row shared between a label and a forty-character identifier, so the identifier took the width and crushed its label into three stacked words. Each pair is a line now, the value truncates with the full text in the title, and cards align their footers across a row.
2026-10-05 19:18:07 +02:00
permitted, err := hasPermission(ctx, tx, administratorID, tenantID, role.ManageUsers)
2026-10-04 16:57:30 +02:00
if err != nil {
2026-10-01 23:15:57 +02:00
return ManagedUser{}, err
}
feat: account menu, database roles, and single logout Bundles the console work from the current branch: the header account control, database-backed roles and permissions, and signing out across the applications that share this sign-in. The account control replaces a separate identity chip and a separate sign-out button, which meant the header carried two controls describing one fact. web/account.js is mounted into every page by a placeholder and owns the keyboard handling, the outside-click behaviour and the sign-out form once. It also fills the trigger's name, which the previous markup left empty. Signing out revokes the hosted-login bearer sessions this installation issued, then redirects to the provider's end_session_endpoint when the discovery document advertises one. Applications that authenticate against this installation therefore lose their session too; one pointed straight at an identity provider is out of its reach and is unchanged. Roles and permissions become rows rather than a closed enum, so a tenant can extend the catalog without a migration, and a role can no longer be granted a capability that is not in it. requireSameOrigin now compares against the origin the request arrived on instead of the configured base URL, which defaulted to http://localhost:8080 and so rejected every write in a deployment that never set it. Two bugs found while verifying in a browser: - A profile picture never rendered. avatarURL built an absolute URL from the configured base URL, and the page's own img-src 'self' blocked it. The console's own pages now take a root-relative path; only /connect/userinfo, read from another origin, needs an absolute one, and it follows the request. - absoluteURL preferred the configuration over the request while its comment claimed the opposite, which also misdirected the logout redirect. Card layout: the footer was one two-column row shared between a label and a forty-character identifier, so the identifier took the width and crushed its label into three stacked words. Each pair is a line now, the value truncates with the full text in the title, and cards align their footers across a row.
2026-10-05 19:18:07 +02:00
if !permitted {
2026-10-01 23:15:57 +02:00
return ManagedUser{}, ErrNotFound
}
feat: account menu, database roles, and single logout Bundles the console work from the current branch: the header account control, database-backed roles and permissions, and signing out across the applications that share this sign-in. The account control replaces a separate identity chip and a separate sign-out button, which meant the header carried two controls describing one fact. web/account.js is mounted into every page by a placeholder and owns the keyboard handling, the outside-click behaviour and the sign-out form once. It also fills the trigger's name, which the previous markup left empty. Signing out revokes the hosted-login bearer sessions this installation issued, then redirects to the provider's end_session_endpoint when the discovery document advertises one. Applications that authenticate against this installation therefore lose their session too; one pointed straight at an identity provider is out of its reach and is unchanged. Roles and permissions become rows rather than a closed enum, so a tenant can extend the catalog without a migration, and a role can no longer be granted a capability that is not in it. requireSameOrigin now compares against the origin the request arrived on instead of the configured base URL, which defaulted to http://localhost:8080 and so rejected every write in a deployment that never set it. Two bugs found while verifying in a browser: - A profile picture never rendered. avatarURL built an absolute URL from the configured base URL, and the page's own img-src 'self' blocked it. The console's own pages now take a root-relative path; only /connect/userinfo, read from another origin, needs an absolute one, and it follows the request. - absoluteURL preferred the configuration over the request while its comment claimed the opposite, which also misdirected the logout redirect. Card layout: the footer was one two-column row shared between a label and a forty-character identifier, so the identifier took the width and crushed its label into three stacked words. Each pair is a line now, the value truncates with the full text in the title, and cards align their footers across a row.
2026-10-05 19:18:07 +02:00
grantable, err := grantablePermissions(ctx, tx, administratorID, tenantID)
if err != nil {
return ManagedUser{}, err
}
resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, grantable)
2026-10-04 16:57:30 +02:00
if err != nil {
return ManagedUser{}, err
}
2026-10-01 23:15:57 +02:00
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, `
2026-10-04 16:57:30 +02:00
INSERT INTO memberships(tenant_id, user_id, role_id) VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id`,
tenantID, user.ID, resolved); err != nil {
2026-10-01 23:15:57 +02:00
return ManagedUser{}, err
}
if err := tx.Commit(); err != nil {
return ManagedUser{}, err
}
2026-10-04 16:57:30 +02:00
granted, err := roleGrants(ctx, s.db, resolved)
if err != nil {
return ManagedUser{}, err
}
user.TenantID, user.RoleID, user.ApplicationIDs = tenantID, resolved, []string{}
if err := s.db.QueryRowContext(ctx, `SELECT slug FROM roles WHERE id = $1`, resolved).Scan(&user.Role); err != nil {
return ManagedUser{}, err
}
user.Permissions = granted
2026-10-01 23:15:57 +02:00
return user, nil
}
2026-10-04 16:57:30 +02:00
// SetUserAccess changes a member's role and replaces their application grants.
func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, roleID string, applicationIDs []string) error {
2026-10-01 23:15:57 +02:00
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
feat: account menu, database roles, and single logout Bundles the console work from the current branch: the header account control, database-backed roles and permissions, and signing out across the applications that share this sign-in. The account control replaces a separate identity chip and a separate sign-out button, which meant the header carried two controls describing one fact. web/account.js is mounted into every page by a placeholder and owns the keyboard handling, the outside-click behaviour and the sign-out form once. It also fills the trigger's name, which the previous markup left empty. Signing out revokes the hosted-login bearer sessions this installation issued, then redirects to the provider's end_session_endpoint when the discovery document advertises one. Applications that authenticate against this installation therefore lose their session too; one pointed straight at an identity provider is out of its reach and is unchanged. Roles and permissions become rows rather than a closed enum, so a tenant can extend the catalog without a migration, and a role can no longer be granted a capability that is not in it. requireSameOrigin now compares against the origin the request arrived on instead of the configured base URL, which defaulted to http://localhost:8080 and so rejected every write in a deployment that never set it. Two bugs found while verifying in a browser: - A profile picture never rendered. avatarURL built an absolute URL from the configured base URL, and the page's own img-src 'self' blocked it. The console's own pages now take a root-relative path; only /connect/userinfo, read from another origin, needs an absolute one, and it follows the request. - absoluteURL preferred the configuration over the request while its comment claimed the opposite, which also misdirected the logout redirect. Card layout: the footer was one two-column row shared between a label and a forty-character identifier, so the identifier took the width and crushed its label into three stacked words. Each pair is a line now, the value truncates with the full text in the title, and cards align their footers across a row.
2026-10-05 19:18:07 +02:00
permitted, err := hasPermission(ctx, tx, administratorID, tenantID, role.ManageUsers)
2026-10-04 16:57:30 +02:00
if err != nil {
return err
}
feat: account menu, database roles, and single logout Bundles the console work from the current branch: the header account control, database-backed roles and permissions, and signing out across the applications that share this sign-in. The account control replaces a separate identity chip and a separate sign-out button, which meant the header carried two controls describing one fact. web/account.js is mounted into every page by a placeholder and owns the keyboard handling, the outside-click behaviour and the sign-out form once. It also fills the trigger's name, which the previous markup left empty. Signing out revokes the hosted-login bearer sessions this installation issued, then redirects to the provider's end_session_endpoint when the discovery document advertises one. Applications that authenticate against this installation therefore lose their session too; one pointed straight at an identity provider is out of its reach and is unchanged. Roles and permissions become rows rather than a closed enum, so a tenant can extend the catalog without a migration, and a role can no longer be granted a capability that is not in it. requireSameOrigin now compares against the origin the request arrived on instead of the configured base URL, which defaulted to http://localhost:8080 and so rejected every write in a deployment that never set it. Two bugs found while verifying in a browser: - A profile picture never rendered. avatarURL built an absolute URL from the configured base URL, and the page's own img-src 'self' blocked it. The console's own pages now take a root-relative path; only /connect/userinfo, read from another origin, needs an absolute one, and it follows the request. - absoluteURL preferred the configuration over the request while its comment claimed the opposite, which also misdirected the logout redirect. Card layout: the footer was one two-column row shared between a label and a forty-character identifier, so the identifier took the width and crushed its label into three stacked words. Each pair is a line now, the value truncates with the full text in the title, and cards align their footers across a row.
2026-10-05 19:18:07 +02:00
if !permitted {
2026-10-04 16:57:30 +02:00
return ErrNotFound
}
feat: account menu, database roles, and single logout Bundles the console work from the current branch: the header account control, database-backed roles and permissions, and signing out across the applications that share this sign-in. The account control replaces a separate identity chip and a separate sign-out button, which meant the header carried two controls describing one fact. web/account.js is mounted into every page by a placeholder and owns the keyboard handling, the outside-click behaviour and the sign-out form once. It also fills the trigger's name, which the previous markup left empty. Signing out revokes the hosted-login bearer sessions this installation issued, then redirects to the provider's end_session_endpoint when the discovery document advertises one. Applications that authenticate against this installation therefore lose their session too; one pointed straight at an identity provider is out of its reach and is unchanged. Roles and permissions become rows rather than a closed enum, so a tenant can extend the catalog without a migration, and a role can no longer be granted a capability that is not in it. requireSameOrigin now compares against the origin the request arrived on instead of the configured base URL, which defaulted to http://localhost:8080 and so rejected every write in a deployment that never set it. Two bugs found while verifying in a browser: - A profile picture never rendered. avatarURL built an absolute URL from the configured base URL, and the page's own img-src 'self' blocked it. The console's own pages now take a root-relative path; only /connect/userinfo, read from another origin, needs an absolute one, and it follows the request. - absoluteURL preferred the configuration over the request while its comment claimed the opposite, which also misdirected the logout redirect. Card layout: the footer was one two-column row shared between a label and a forty-character identifier, so the identifier took the width and crushed its label into three stacked words. Each pair is a line now, the value truncates with the full text in the title, and cards align their footers across a row.
2026-10-05 19:18:07 +02:00
grantable, err := grantablePermissions(ctx, tx, administratorID, tenantID)
if err != nil {
return err
}
resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, grantable)
2026-10-04 16:57:30 +02:00
if err != nil {
return err
}
if err := ensureTenantKeepsAnOwner(ctx, tx, tenantID, userID, resolved); err != nil {
return err
}
2026-10-01 23:15:57 +02:00
result, err := tx.ExecContext(ctx, `
2026-10-04 16:57:30 +02:00
UPDATE memberships SET role_id = $3
WHERE tenant_id = $1 AND user_id = $2 AND role_id IS DISTINCT FROM $3`,
tenantID, userID, resolved)
2026-10-01 23:15:57 +02:00
if err != nil {
return err
}
if count, _ := result.RowsAffected(); count == 0 {
2026-10-04 16:57:30 +02:00
// Either the membership does not exist or the role is unchanged.
var exists bool
if err := tx.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2)`,
tenantID, userID).Scan(&exists); err != nil {
return err
}
if !exists {
return ErrNotFound
}
2026-10-01 23:15:57 +02:00
}
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()
}
2026-10-04 16:57:30 +02:00
// ErrLastOwner is returned when a change would leave a tenant without an owner.
var ErrLastOwner = errors.New("a tenant must keep at least one owner")
// ensureTenantKeepsAnOwner blocks demoting or moving away the only membership
// that still holds the owner role. Without this a tenant administrator could
// lock every administrator out of their own tenant with a single edit.
func ensureTenantKeepsAnOwner(ctx context.Context, tx *sql.Tx, tenantID, userID, newRoleID string) error {
var losesOwner bool
if err := tx.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM memberships m
JOIN roles r ON r.id = m.role_id
WHERE m.tenant_id = $1 AND m.user_id = $2 AND r.slug = 'owner' AND m.role_id <> $3
)`, tenantID, userID, newRoleID).Scan(&losesOwner); err != nil {
return err
}
if !losesOwner {
return nil
}
var remaining int
if err := tx.QueryRowContext(ctx, `
SELECT count(*)
FROM memberships m
JOIN roles r ON r.id = m.role_id
WHERE m.tenant_id = $1 AND r.slug = 'owner' AND m.user_id <> $2`,
tenantID, userID).Scan(&remaining); err != nil {
return err
}
if remaining == 0 {
return ErrLastOwner
}
return nil
}
2026-10-01 23:15:57 +02:00
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
2026-10-04 12:24:13 +02:00
SET redirect_uris = $4, client_secret_hash = $5, updated_at = now()
2026-10-04 16:57:30 +02:00
WHERE a.id = $1 AND `+permissionPredicate("a.tenant_id", "$2", "$3")+`
2026-10-01 23:15:57 +02:00
RETURNING a.id, a.client_id, to_json(a.redirect_uris), true`,
2026-10-04 16:57:30 +02:00
applicationID, administratorID, string(role.ManageApplications), redirectURIs, secretHash).
2026-10-01 23:15:57 +02:00
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
2026-10-04 16:57:30 +02:00
var permissions []byte
2026-10-01 23:15:57 +02:00
err = tx.QueryRowContext(ctx, `
UPDATE client_authorization_codes c SET used_at = now()
2026-10-04 12:24:13 +02:00
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
2026-10-04 16:57:30 +02:00
LEFT JOIN roles r ON r.id = m.role_id
2026-10-01 23:15:57 +02:00
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
2026-10-04 12:24:13 +02:00
AND ua.user_id = c.user_id AND ua.application_id = a.id
2026-10-01 23:15:57 +02:00
AND a.client_id = $3 AND a.client_secret_hash = $4
2026-10-04 12:24:13 +02:00
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug,
2026-10-04 16:57:30 +02:00
COALESCE(r.slug, 'user'), `+clientPermissions+`, u.avatar_key`,
codeHash, redirectURI, clientID, secretHash).
2026-10-04 12:24:13 +02:00
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug,
2026-10-04 16:57:30 +02:00
&user.ApplicationID, &user.Application, &user.Role, &permissions, &user.AvatarKey)
2026-10-01 23:15:57 +02:00
if errors.Is(err, sql.ErrNoRows) {
return ClientUser{}, time.Time{}, ErrNotFound
}
if err != nil {
return ClientUser{}, time.Time{}, err
}
2026-10-04 16:57:30 +02:00
if err := json.Unmarshal(permissions, &user.Permissions); err != nil {
return ClientUser{}, time.Time{}, err
}
2026-10-01 23:15:57 +02:00
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
2026-10-04 16:57:30 +02:00
var permissions []byte
2026-10-01 23:15:57 +02:00
err := s.db.QueryRowContext(ctx, `
UPDATE client_user_sessions s SET last_used_at = now()
2026-10-04 12:24:13 +02:00
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
2026-10-04 16:57:30 +02:00
LEFT JOIN roles r ON r.id = m.role_id
2026-10-01 23:15:57 +02:00
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now()
2026-10-04 12:24:13 +02:00
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,
2026-10-04 16:57:30 +02:00
COALESCE(r.slug, 'user'), `+clientPermissions+`, u.avatar_key`,
tokenHash).
2026-10-04 12:24:13 +02:00
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug,
2026-10-04 16:57:30 +02:00
&user.ApplicationID, &user.Application, &user.Role, &permissions, &user.AvatarKey)
2026-10-01 23:15:57 +02:00
if errors.Is(err, sql.ErrNoRows) {
return ClientUser{}, ErrNotFound
}
2026-10-04 16:57:30 +02:00
if err != nil {
return ClientUser{}, err
}
if err := json.Unmarshal(permissions, &user.Permissions); err != nil {
return ClientUser{}, err
}
return user, nil
2026-10-01 23:15:57 +02:00
}
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
}
feat: account menu, database roles, and single logout Bundles the console work from the current branch: the header account control, database-backed roles and permissions, and signing out across the applications that share this sign-in. The account control replaces a separate identity chip and a separate sign-out button, which meant the header carried two controls describing one fact. web/account.js is mounted into every page by a placeholder and owns the keyboard handling, the outside-click behaviour and the sign-out form once. It also fills the trigger's name, which the previous markup left empty. Signing out revokes the hosted-login bearer sessions this installation issued, then redirects to the provider's end_session_endpoint when the discovery document advertises one. Applications that authenticate against this installation therefore lose their session too; one pointed straight at an identity provider is out of its reach and is unchanged. Roles and permissions become rows rather than a closed enum, so a tenant can extend the catalog without a migration, and a role can no longer be granted a capability that is not in it. requireSameOrigin now compares against the origin the request arrived on instead of the configured base URL, which defaulted to http://localhost:8080 and so rejected every write in a deployment that never set it. Two bugs found while verifying in a browser: - A profile picture never rendered. avatarURL built an absolute URL from the configured base URL, and the page's own img-src 'self' blocked it. The console's own pages now take a root-relative path; only /connect/userinfo, read from another origin, needs an absolute one, and it follows the request. - absoluteURL preferred the configuration over the request while its comment claimed the opposite, which also misdirected the logout redirect. Card layout: the footer was one two-column row shared between a label and a forty-character identifier, so the identifier took the width and crushed its label into three stacked words. Each pair is a line now, the value truncates with the full text in the title, and cards align their footers across a row.
2026-10-05 19:18:07 +02:00
// RevokeUserClientSessions ends every hosted-login session belonging to a user and
// reports how many were live.
//
// Go Loose issues the bearer tokens that tenant applications authenticate with, so
// this is what makes signing out here sign you out there: the tokens stop resolving
// immediately, without the other application having to be told or even to ask. It is
// the part of single logout that is enforced here rather than at the identity
// provider.
//
// A user with no sessions is not an error, so the count is returned rather than
// ErrNotFound: logging out twice, or from a session that never used a client
// application, must still succeed.
func (s *Store) RevokeUserClientSessions(ctx context.Context, userID string) (int, error) {
if userID == "" {
return 0, nil
}
result, err := s.db.ExecContext(ctx, `
UPDATE client_user_sessions SET revoked_at = now()
WHERE user_id = $1 AND revoked_at IS NULL`, userID)
if err != nil {
return 0, err
}
count, err := result.RowsAffected()
if err != nil {
return 0, err
}
return int(count), nil
}
// CountActiveClientSessions reports how many hosted-login sessions a user holds.
// The account menu uses it to say whether signing out will reach other applications.
func (s *Store) CountActiveClientSessions(ctx context.Context, userID string) (int, error) {
if userID == "" {
return 0, nil
}
var count int
err := s.db.QueryRowContext(ctx, `
SELECT count(*) FROM client_user_sessions
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > now()`, userID).Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}