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.
224 lines
7.8 KiB
Go
224 lines
7.8 KiB
Go
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'
|
|
]`
|