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.
This commit is contained in:
parent
3fdc8cb489
commit
c7c81fb65c
43 changed files with 2248 additions and 298 deletions
224
internal/store/permissions.go
Normal file
224
internal/store/permissions.go
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/danyel/go-loose/internal/role"
|
||||
)
|
||||
|
||||
// Permission is one entry in the installation-wide catalog. The nine seeded by
|
||||
// migration 0007 are the capabilities the console enforces; anything added later
|
||||
// is grantable and reported to client applications but is not checked here,
|
||||
// because nothing in the request path tests for it.
|
||||
type Permission struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
System bool `json:"system"`
|
||||
// RoleCount is how many roles currently grant it. A permission that no role
|
||||
// grants can be removed again.
|
||||
RoleCount int `json:"role_count"`
|
||||
// Enforced reports whether the application itself checks for this
|
||||
// permission. The console surfaces this so an operator is not misled into
|
||||
// believing a custom capability is enforced.
|
||||
Enforced bool `json:"enforced"`
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrPermissionInUse is returned when a permission a role still grants is
|
||||
// deleted.
|
||||
ErrPermissionInUse = errors.New("permission is still granted by a role")
|
||||
// ErrPermissionTaken is returned when a permission name already exists.
|
||||
ErrPermissionTaken = errors.New("permission name is already in use")
|
||||
// ErrPermissionSystem is returned when a built-in permission would be edited
|
||||
// or deleted.
|
||||
ErrPermissionSystem = errors.New("built-in permissions cannot be changed")
|
||||
)
|
||||
|
||||
// permissionName constrains a name to lowercase dotted words such as
|
||||
// "reports.export". The database enforces the same shape.
|
||||
var permissionName = regexp.MustCompile(`^[a-z0-9][a-z0-9]*(?:[.-][a-z0-9]+)*$`)
|
||||
|
||||
// ValidPermissionName reports whether a name is well formed.
|
||||
func ValidPermissionName(name string) bool {
|
||||
return len(name) >= 3 && len(name) <= 100 && permissionName.MatchString(name)
|
||||
}
|
||||
|
||||
// ListPermissions returns the whole catalog, most sensitive first for the
|
||||
// built-ins and alphabetically after them.
|
||||
func (s *Store) ListPermissions(ctx context.Context) ([]Permission, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT p.name, p.label, p.description, p.is_system,
|
||||
(SELECT count(*) FROM role_permissions rp WHERE rp.permission = p.name)
|
||||
FROM permissions p
|
||||
ORDER BY array_position(`+permissionOrder+`, p.name) NULLS LAST, p.name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]Permission, 0)
|
||||
for rows.Next() {
|
||||
var item Permission
|
||||
if err := rows.Scan(&item.Name, &item.Label, &item.Description, &item.System, &item.RoleCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Enforced = role.Permission(item.Name).Valid()
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// CreatePermission adds a name to the catalog so that roles can grant it.
|
||||
// Requires either a system administrator or tenants.manage, because the catalog
|
||||
// is shared by every tenant rather than scoped to one.
|
||||
func (s *Store) CreatePermission(ctx context.Context, actorID, name, label, description string) (Permission, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
label = strings.TrimSpace(label)
|
||||
description = strings.TrimSpace(description)
|
||||
if !ValidPermissionName(name) {
|
||||
return Permission{}, fmt.Errorf("%w: use lowercase words separated by dots", ErrUnknownPermission)
|
||||
}
|
||||
if label == "" || len(label) > 100 {
|
||||
return Permission{}, fmt.Errorf("%w: label must be between 1 and 100 characters", ErrUnknownPermission)
|
||||
}
|
||||
if len(description) > 500 {
|
||||
return Permission{}, fmt.Errorf("%w: description must be 500 characters or fewer", ErrUnknownPermission)
|
||||
}
|
||||
if role.Permission(name).Valid() {
|
||||
// The name is one the application enforces; it is already seeded, so this
|
||||
// can only be a duplicate.
|
||||
return Permission{}, ErrPermissionTaken
|
||||
}
|
||||
permitted, err := s.mayAdministerCatalog(ctx, actorID)
|
||||
if err != nil {
|
||||
return Permission{}, err
|
||||
}
|
||||
if !permitted {
|
||||
return Permission{}, ErrNotFound
|
||||
}
|
||||
item := Permission{Name: name, Label: label, Description: description, Enforced: false}
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO permissions(name, label, description)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING name, label, description, is_system,
|
||||
(SELECT count(*) FROM role_permissions rp WHERE rp.permission = permissions.name)`,
|
||||
name, label, description).
|
||||
Scan(&item.Name, &item.Label, &item.Description, &item.System, &item.RoleCount)
|
||||
if isUniqueViolation(err) {
|
||||
return Permission{}, ErrPermissionTaken
|
||||
}
|
||||
if err != nil {
|
||||
return Permission{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// DeletePermission removes a catalog entry that no role grants. Built-in
|
||||
// permissions are kept: the console enforces them and dropping one would leave a
|
||||
// membership holding a capability nothing recognizes.
|
||||
func (s *Store) DeletePermission(ctx context.Context, actorID, name string) error {
|
||||
permitted, err := s.mayAdministerCatalog(ctx, actorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !permitted {
|
||||
return ErrNotFound
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var (
|
||||
label string
|
||||
system bool
|
||||
grants int
|
||||
)
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT label, is_system,
|
||||
(SELECT count(*) FROM role_permissions rp WHERE rp.permission = permissions.name)
|
||||
FROM permissions WHERE name = $1 FOR UPDATE`, name).
|
||||
Scan(&label, &system, &grants)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if system {
|
||||
return ErrPermissionSystem
|
||||
}
|
||||
if grants > 0 {
|
||||
return ErrPermissionInUse
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM permissions WHERE name = $1`, name); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// mayAdministerCatalog reports whether the actor may change the shared catalog.
|
||||
// The catalog is installation-wide, so this is a system administrator or an owner
|
||||
// of some tenant, which is the permission that already means "administer the
|
||||
// installation".
|
||||
func (s *Store) mayAdministerCatalog(ctx context.Context, actorID string) (bool, error) {
|
||||
return mayAdministerCatalog(ctx, s.db, actorID)
|
||||
}
|
||||
|
||||
func mayAdministerCatalog(ctx context.Context, q querier, actorID string) (bool, error) {
|
||||
var permitted bool
|
||||
err := q.QueryRowContext(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM system_administrators WHERE user_id = $1)
|
||||
OR `+permissionPredicate("scoped.tenant_id", "$1", "$2"),
|
||||
actorID, string(role.ManageTenants)).Scan(&permitted)
|
||||
return permitted, err
|
||||
}
|
||||
|
||||
// grantablePermissions returns what an actor may hand to somebody: the
|
||||
// permissions they hold, plus -- for whoever administers the shared catalog --
|
||||
// every custom permission.
|
||||
//
|
||||
// The second half is what makes the catalog usable. A permission nobody holds
|
||||
// yet could not otherwise be granted, because the escalation guard refuses to
|
||||
// hand out a capability the granter does not have. Letting the author of a
|
||||
// permission pass it on resolves that without weakening the guard for enforced
|
||||
// permissions, which are never in the custom set.
|
||||
func grantablePermissions(ctx context.Context, q querier, actorID, tenantID string) ([]string, error) {
|
||||
held, err := heldPermissions(ctx, q, actorID, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
administers, err := mayAdministerCatalog(ctx, q, actorID)
|
||||
if err != nil || !administers {
|
||||
return held, err
|
||||
}
|
||||
rows, err := q.QueryContext(ctx, `SELECT name FROM permissions WHERE NOT is_system`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !slices.Contains(held, name) {
|
||||
held = append(held, name)
|
||||
}
|
||||
}
|
||||
return held, rows.Err()
|
||||
}
|
||||
|
||||
// permissionOrder lists the enforced capabilities so the catalog keeps the same
|
||||
// most-sensitive-first order the role editor used before it was a table.
|
||||
var permissionOrder = `ARRAY[
|
||||
'tenants.manage', 'roles.manage', 'users.manage', 'applications.manage',
|
||||
'keys.manage', 'contracts.manage', 'users.read', 'console.read', 'profile.edit'
|
||||
]`
|
||||
Loading…
Add table
Add a link
Reference in a new issue