Instance authorization foundation #5

Merged
tony merged 1 commits from codex/instance-authorization-foundation AGit into main 2026-08-07 16:47:25 +02:00
12 changed files with 1239 additions and 37 deletions
+1 -1
View File
@@ -72,7 +72,7 @@ Only the main application's HTTP port is published. The agent and game-managemen
## Status
The first four roadmap foundations are implemented: the main application and authentication, the restricted agent boundary, the validated embedded catalog and the registered instance lifecycle. Administrators can install, inspect, start, stop, restart and safely remove only a container through authenticated typed operations. SQLite records serialized operation phases and desired/observed state, while the agent independently checks the immutable template, canonical plan, ports, storage, resources and registration binding. Backups, per-instance authorization, updates and the WebAssembly runtime remain later roadmap work.
The first five roadmap foundations are implemented: the main application and authentication, the restricted agent boundary, the validated embedded catalog, the registered instance lifecycle and backend-enforced per-instance authorization. Administrators can manage local users, assign user or manager memberships, apply explicit permission overrides and review installation requests without approval causing deployment. Assigned users receive the documented safe baseline; managers receive the operational baseline, while restore remains an explicit grant. SQLite records identities, access policy, requests, serialized operation phases and desired/observed instance state. Backups, updates and the WebAssembly runtime remain later roadmap work.
## Validate the specification
+16 -6
View File
@@ -59,12 +59,22 @@ below configured roots with symlinks refused. Docker containers always use the
fixed restricted baseline; callers cannot provide labels, capabilities, devices,
network modes or arbitrary Docker options.
Lifecycle API operations are administrator-only until the per-instance
authorization milestone. Install, start, stop, restart and container-only delete
are serialized per instance and recorded in `instance_operations`. Desired and
observed states are reconciled at startup and every minute. Container-only delete
removes neither the SQLite intent nor host paths; player data and backups remain
untouched and the missing container stays visible for reconciliation.
Lifecycle API operations are authorized in the backend against the authenticated
identity and the target instance. Global administrators retain implicit access;
assigned users can inspect, view metrics, start and stop, while managers also
receive the documented operational baseline. Explicit deny overrides take
precedence over role baselines and allows. Install and container-only delete
remain administrator operations. Operations are serialized per instance and
recorded in `instance_operations`; desired and observed states are reconciled at
startup and every minute. Container-only delete removes neither the SQLite
intent nor host paths, so player data and backups remain untouched.
The authorization foundation exposes JSON APIs for local user creation,
memberships, per-user overrides and installation requests. Mutations require the
session CSRF token. User creation, membership changes, override changes and
request review additionally require an administrator session authenticated in
the previous ten minutes. Approving a request only records the decision and the
requested values; it never creates a draft or contacts the restricted agent.
At main-application startup, every embedded `catalog/*/template.yaml` is
validated against `specs/template.schema.json`, checked for cross-reference and
+19 -1
View File
@@ -86,7 +86,25 @@ Admin-only system permissions are not delegated per instance in V1.
Client-provided instance IDs, roles and permission lists are never trusted. Object lookup and permission evaluation occur in one application-layer call to prevent confused-deputy errors.
## Persistence and request workflow
Instance memberships and overrides are stored in SQLite with foreign keys to
the registered instance and local user. Removing a membership also removes its
overrides. A user can have at most one pending installation request for a given
template version; users only list their own requests, while administrators list
all requests.
Approving or refusing a request is an atomic state transition from `pending`.
Refusal requires a reason. Approval retains the requested template, optional
name, player estimate, schedule, mods flag and message for a later creation
workflow, but deliberately performs no deployment and creates no instance.
All API mutations validate the session-bound CSRF token. User creation,
membership and override changes, and request review require an administrator
authentication no older than ten minutes. Authorization failures return a
generic denial so an unassigned user cannot use object identifiers to discover
instances.
## Sensitive-action safeguards
Restore, destructive delete, membership changes, secret rotation and security configuration require recent authentication. Data removal requires separate checkboxes and typed instance-name confirmation. A manager never gains new abilities merely because a module exposes a capability.
+50 -7
View File
@@ -36,9 +36,11 @@ const (
// User is the authenticated principal exposed to application handlers.
type User struct {
ID string
Username string
Role string
ID string `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
AuthenticatedAt time.Time `json:"-"`
}
// Session contains a new opaque browser credential and CSRF token.
@@ -150,10 +152,10 @@ func (s *Service) Authenticate(ctx context.Context, token string) (User, error)
}
now := s.now().UTC()
var user User
var expiresAt, lastSeenAt string
err := s.db.QueryRowContext(ctx, `SELECT u.id, u.username, u.global_role, s.expires_at, s.last_seen_at
var expiresAt, lastSeenAt, createdAt string
err := s.db.QueryRowContext(ctx, `SELECT u.id, u.username, u.global_role, s.expires_at, s.last_seen_at, s.created_at
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.id_hash = ? AND u.disabled_at IS NULL`, digest(token)).Scan(&user.ID, &user.Username, &user.Role, &expiresAt, &lastSeenAt)
WHERE s.id_hash = ? AND u.disabled_at IS NULL`, digest(token)).Scan(&user.ID, &user.Username, &user.Role, &expiresAt, &lastSeenAt, &createdAt)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrInvalidSession
}
@@ -162,16 +164,57 @@ func (s *Service) Authenticate(ctx context.Context, token string) (User, error)
}
expires, err1 := time.Parse(time.RFC3339Nano, expiresAt)
lastSeen, err2 := time.Parse(time.RFC3339Nano, lastSeenAt)
if err1 != nil || err2 != nil || !now.Before(expires) || now.Sub(lastSeen) > idleLifetime {
authenticatedAt, err3 := time.Parse(time.RFC3339Nano, createdAt)
if err1 != nil || err2 != nil || err3 != nil || !now.Before(expires) || now.Sub(lastSeen) > idleLifetime {
_ = s.Revoke(ctx, token)
return User{}, ErrInvalidSession
}
if _, err := s.db.ExecContext(ctx, "UPDATE sessions SET last_seen_at = ? WHERE id_hash = ?", now.Format(time.RFC3339Nano), digest(token)); err != nil {
return User{}, fmt.Errorf("refresh session: %w", err)
}
user.AuthenticatedAt = authenticatedAt
return user, nil
}
// CreateUser adds a local identity after the caller has enforced administrator
// authorization and recent authentication.
func (s *Service) CreateUser(ctx context.Context, username, password, role string) (User, error) {
username = strings.TrimSpace(username)
if role != "user" && role != "admin" {
return User{}, errors.New("invalid global role")
}
if err := validateCredentials(username, password); err != nil {
return User{}, err
}
hash, err := hashPassword(password)
if err != nil {
return User{}, err
}
user := User{ID: randomToken(18), Username: username, Role: role}
_, err = s.db.ExecContext(ctx, "INSERT INTO users(id, username, password_hash, global_role, created_at) VALUES (?, ?, ?, ?, ?)", user.ID, username, hash, role, s.now().UTC().Format(time.RFC3339Nano))
if err != nil {
return User{}, fmt.Errorf("create local user: %w", err)
}
return user, nil
}
func (s *Service) ListUsers(ctx context.Context) ([]User, error) {
rows, err := s.db.QueryContext(ctx, "SELECT id, username, global_role, disabled_at IS NOT NULL FROM users ORDER BY username COLLATE NOCASE")
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
defer rows.Close()
var users []User
for rows.Next() {
var user User
if err := rows.Scan(&user.ID, &user.Username, &user.Role, &user.Disabled); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
users = append(users, user)
}
return users, rows.Err()
}
// ValidateCSRF checks that a token belongs to the current session.
func (s *Service) ValidateCSRF(ctx context.Context, sessionToken, csrfToken string) bool {
if sessionToken == "" || csrfToken == "" {
+53 -4
View File
@@ -2,6 +2,7 @@ package auth
import (
"context"
"database/sql"
"errors"
"path/filepath"
"strings"
@@ -9,7 +10,8 @@ import (
"testing"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/migrations"
_ "modernc.org/sqlite"
)
func TestBootstrapLoginSessionAndRevocation(t *testing.T) {
@@ -178,12 +180,59 @@ func TestVerifyPasswordRejectsMalformedArgon2idEncodings(t *testing.T) {
}
}
func testService(t *testing.T) *Service {
t.Helper()
db, err := sqlite.Open(context.Background(), filepath.Join(t.TempDir(), "dogama.db"))
func TestCreateAndListUserTracksAuthenticationTime(t *testing.T) {
service := testService(t)
ctx := context.Background()
if err := service.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
created, err := service.CreateUser(ctx, "player", "another correct battery staple", "user")
if err != nil {
t.Fatal(err)
}
if created.ID == "" || created.Username != "player" || created.Role != "user" {
t.Fatalf("created user = %#v", created)
}
users, err := service.ListUsers(ctx)
if err != nil || len(users) != 2 {
t.Fatalf("users = %#v, error = %v", users, err)
}
session, err := service.Login(ctx, "player", "another correct battery staple", "192.0.2.10:1234")
if err != nil {
t.Fatal(err)
}
authenticated, err := service.Authenticate(ctx, session.Token)
if err != nil || authenticated.AuthenticatedAt.IsZero() {
t.Fatalf("authenticated user = %#v, error = %v", authenticated, err)
}
}
func testService(t *testing.T) *Service {
t.Helper()
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
db.SetMaxOpenConns(1)
if _, err := db.Exec("PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000"); err != nil {
t.Fatal(err)
}
entries, err := migrations.Files.ReadDir(".")
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
body, err := migrations.Files.ReadFile(entry.Name())
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(string(body)); err != nil {
t.Fatalf("apply %s: %v", entry.Name(), err)
}
}
t.Cleanup(func() { db.Close() })
return New(db)
}
+277
View File
@@ -0,0 +1,277 @@
// Package authorization enforces global and per-instance permissions.
package authorization
import (
"context"
"errors"
"sort"
"strings"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
)
var (
ErrDenied = errors.New("permission denied")
ErrInvalidInput = errors.New("invalid authorization input")
ErrNotFound = errors.New("authorization object not found")
ErrConflict = errors.New("authorization conflict")
ErrRecentAuth = errors.New("recent authentication required")
)
const RecentAuthenticationWindow = 10 * time.Minute
const (
PermissionInstanceView = "instance.view"
PermissionInstanceStart = "instance.start"
PermissionInstanceStop = "instance.stop"
PermissionInstanceRestart = "instance.restart"
PermissionInstanceUpdate = "instance.update"
PermissionInstanceConfigure = "instance.configure"
PermissionInstanceDelete = "instance.delete"
PermissionInstanceWelcomeEdit = "instance.welcome.edit"
PermissionMetricsView = "metrics.view"
PermissionPlayersView = "players.view"
PermissionPlayersKick = "players.kick"
PermissionPlayersBan = "players.ban"
PermissionPlayersUnban = "players.unban"
PermissionAnnouncementsSend = "announcements.send"
PermissionLogsView = "logs.view"
PermissionModsManage = "mods.manage"
PermissionBackupCreate = "backup.create"
PermissionBackupList = "backup.list"
PermissionBackupExport = "backup.export"
PermissionBackupRestore = "backup.restore"
PermissionBackupDelete = "backup.delete"
PermissionRequestCreate = "request.create"
)
var allPermissions = map[string]struct{}{
PermissionInstanceView: {}, PermissionInstanceStart: {}, PermissionInstanceStop: {}, PermissionInstanceRestart: {},
PermissionInstanceUpdate: {}, PermissionInstanceConfigure: {}, PermissionInstanceDelete: {}, PermissionInstanceWelcomeEdit: {},
PermissionMetricsView: {}, PermissionPlayersView: {}, PermissionPlayersKick: {}, PermissionPlayersBan: {}, PermissionPlayersUnban: {},
PermissionAnnouncementsSend: {}, PermissionLogsView: {}, PermissionModsManage: {}, PermissionBackupCreate: {}, PermissionBackupList: {},
PermissionBackupExport: {}, PermissionBackupRestore: {}, PermissionBackupDelete: {}, PermissionRequestCreate: {},
}
var userBaseline = permissionSet(
PermissionInstanceView, PermissionInstanceStart, PermissionInstanceStop,
PermissionMetricsView, PermissionPlayersView, PermissionRequestCreate,
)
var managerBaseline = permissionSet(
PermissionInstanceView, PermissionInstanceStart, PermissionInstanceStop, PermissionInstanceRestart,
PermissionInstanceUpdate, PermissionInstanceWelcomeEdit, PermissionMetricsView, PermissionPlayersView,
PermissionPlayersKick, PermissionPlayersBan, PermissionPlayersUnban, PermissionAnnouncementsSend,
PermissionLogsView, PermissionModsManage, PermissionBackupCreate, PermissionBackupList, PermissionRequestCreate,
)
type Access struct {
InstanceExists bool
MembershipRole string
Overrides map[string]string
}
type Membership struct {
InstanceID string `json:"instance_id"`
UserID string `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
Overrides map[string]string `json:"overrides"`
}
type InstallationRequest struct {
ID string `json:"id"`
RequestedBy string `json:"requested_by"`
RequesterName string `json:"requester_name"`
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
SuggestedName string `json:"suggested_name,omitempty"`
PlayerEstimate int `json:"player_estimate,omitempty"`
DesiredSchedule string `json:"desired_schedule,omitempty"`
ModsRequested bool `json:"mods_requested"`
Message string `json:"message,omitempty"`
Status string `json:"status"`
ReviewedBy string `json:"reviewed_by,omitempty"`
ReviewerName string `json:"reviewer_name,omitempty"`
ReviewReason string `json:"review_reason,omitempty"`
CreatedAt string `json:"created_at"`
ReviewedAt string `json:"reviewed_at,omitempty"`
}
type RequestInput struct {
ID string
TemplateID string
TemplateVersion string
SuggestedName string
PlayerEstimate int
DesiredSchedule string
ModsRequested bool
Message string
}
type Repository interface {
ResolveAccess(context.Context, string, string) (Access, error)
SetMembership(context.Context, string, string, string, string) error
DeleteMembership(context.Context, string, string) error
SetPermissionOverride(context.Context, string, string, string, string, string) error
DeletePermissionOverride(context.Context, string, string, string) error
ListMemberships(context.Context, string) ([]Membership, error)
CreateInstallationRequest(context.Context, string, RequestInput) (InstallationRequest, error)
ListInstallationRequests(context.Context, string, bool) ([]InstallationRequest, error)
ReviewInstallationRequest(context.Context, string, string, string, string) (InstallationRequest, error)
}
type Service struct {
repository Repository
now func() time.Time
}
func New(repository Repository) *Service { return &Service{repository: repository, now: time.Now} }
func Permissions() []string {
result := make([]string, 0, len(allPermissions))
for permission := range allPermissions {
result = append(result, permission)
}
sort.Strings(result)
return result
}
func (s *Service) Require(ctx context.Context, principal auth.User, instanceID, permission string) error {
if principal.ID == "" || principal.Disabled || !knownPermission(permission) {
return ErrDenied
}
if principal.Role == "admin" {
return s.requireRecentForPermission(principal, permission)
}
if principal.Role != "user" || instanceID == "" {
return ErrDenied
}
access, err := s.repository.ResolveAccess(ctx, principal.ID, instanceID)
if err != nil {
return err
}
if !access.InstanceExists || (access.MembershipRole != "user" && access.MembershipRole != "manager") {
return ErrDenied
}
if access.Overrides[permission] == "deny" {
return ErrDenied
}
baseline := userBaseline
if access.MembershipRole == "manager" {
baseline = managerBaseline
}
if baseline[permission] || access.Overrides[permission] == "allow" {
return s.requireRecentForPermission(principal, permission)
}
return ErrDenied
}
func (s *Service) SetMembership(ctx context.Context, actor auth.User, instanceID, userID, role string) error {
if err := s.requireRecentAdmin(actor); err != nil {
return err
}
if instanceID == "" || userID == "" || (role != "user" && role != "manager") {
return ErrInvalidInput
}
return s.repository.SetMembership(ctx, actor.ID, instanceID, userID, role)
}
func (s *Service) RequireRecentAdmin(actor auth.User) error { return s.requireRecentAdmin(actor) }
func (s *Service) DeleteMembership(ctx context.Context, actor auth.User, instanceID, userID string) error {
if err := s.requireRecentAdmin(actor); err != nil {
return err
}
if instanceID == "" || userID == "" {
return ErrInvalidInput
}
return s.repository.DeleteMembership(ctx, instanceID, userID)
}
func (s *Service) SetOverride(ctx context.Context, actor auth.User, instanceID, userID, permission, effect string) error {
if err := s.requireRecentAdmin(actor); err != nil {
return err
}
if instanceID == "" || userID == "" || !knownPermission(permission) || (effect != "allow" && effect != "deny") {
return ErrInvalidInput
}
return s.repository.SetPermissionOverride(ctx, actor.ID, instanceID, userID, permission, effect)
}
func (s *Service) DeleteOverride(ctx context.Context, actor auth.User, instanceID, userID, permission string) error {
if err := s.requireRecentAdmin(actor); err != nil {
return err
}
if instanceID == "" || userID == "" || !knownPermission(permission) {
return ErrInvalidInput
}
return s.repository.DeletePermissionOverride(ctx, instanceID, userID, permission)
}
func (s *Service) ListMemberships(ctx context.Context, actor auth.User, instanceID string) ([]Membership, error) {
if actor.Role != "admin" || actor.Disabled {
return nil, ErrDenied
}
return s.repository.ListMemberships(ctx, instanceID)
}
func (s *Service) CreateInstallationRequest(ctx context.Context, actor auth.User, input RequestInput) (InstallationRequest, error) {
if actor.ID == "" || actor.Disabled || (actor.Role != "user" && actor.Role != "admin") {
return InstallationRequest{}, ErrDenied
}
input.SuggestedName, input.DesiredSchedule, input.Message = strings.TrimSpace(input.SuggestedName), strings.TrimSpace(input.DesiredSchedule), strings.TrimSpace(input.Message)
if input.ID == "" || input.TemplateID == "" || input.TemplateVersion == "" || len(input.SuggestedName) > 100 || strings.ContainsAny(input.SuggestedName, `/\\`) || input.PlayerEstimate < 0 || input.PlayerEstimate > 10000 || len(input.DesiredSchedule) > 200 || len(input.Message) > 1000 {
return InstallationRequest{}, ErrInvalidInput
}
return s.repository.CreateInstallationRequest(ctx, actor.ID, input)
}
func (s *Service) ListInstallationRequests(ctx context.Context, actor auth.User) ([]InstallationRequest, error) {
if actor.ID == "" || actor.Disabled {
return nil, ErrDenied
}
return s.repository.ListInstallationRequests(ctx, actor.ID, actor.Role == "admin")
}
func (s *Service) ReviewInstallationRequest(ctx context.Context, actor auth.User, requestID, decision, reason string) (InstallationRequest, error) {
if err := s.requireRecentAdmin(actor); err != nil {
return InstallationRequest{}, err
}
reason = strings.TrimSpace(reason)
if requestID == "" || (decision != "approved" && decision != "refused") || len(reason) > 1000 || (decision == "refused" && reason == "") {
return InstallationRequest{}, ErrInvalidInput
}
return s.repository.ReviewInstallationRequest(ctx, actor.ID, requestID, decision, reason)
}
func (s *Service) requireRecentAdmin(actor auth.User) error {
if actor.ID == "" || actor.Disabled || actor.Role != "admin" {
return ErrDenied
}
if actor.AuthenticatedAt.IsZero() || s.now().Sub(actor.AuthenticatedAt) > RecentAuthenticationWindow {
return ErrRecentAuth
}
return nil
}
func (s *Service) requireRecentForPermission(actor auth.User, permission string) error {
if permission != PermissionInstanceConfigure && permission != PermissionInstanceDelete && permission != PermissionBackupRestore && permission != PermissionBackupDelete {
return nil
}
age := s.now().Sub(actor.AuthenticatedAt)
if actor.AuthenticatedAt.IsZero() || age < 0 || age > RecentAuthenticationWindow {
return ErrRecentAuth
}
return nil
}
func knownPermission(permission string) bool { _, ok := allPermissions[permission]; return ok }
func permissionSet(values ...string) map[string]bool {
result := make(map[string]bool, len(values))
for _, value := range values {
result[value] = true
}
return result
}
+154
View File
@@ -0,0 +1,154 @@
package authorization_test
import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"time"
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
func TestInstanceBaselinesOverridesAndIdentifierSubstitution(t *testing.T) {
ctx, _, repository, _, admin, user, instanceID, _ := authorizationFixture(t)
service := authorization.New(repository)
if err := service.Require(ctx, user, instanceID, authorization.PermissionInstanceView); !errors.Is(err, authorization.ErrDenied) {
t.Fatalf("unassigned access error = %v", err)
}
if err := service.SetMembership(ctx, admin, instanceID, user.ID, "user"); err != nil {
t.Fatal(err)
}
for _, permission := range []string{authorization.PermissionInstanceView, authorization.PermissionInstanceStart, authorization.PermissionInstanceStop} {
if err := service.Require(ctx, user, instanceID, permission); err != nil {
t.Fatalf("user permission %s: %v", permission, err)
}
}
if err := service.Require(ctx, user, instanceID, authorization.PermissionInstanceRestart); !errors.Is(err, authorization.ErrDenied) {
t.Fatalf("user restart error = %v", err)
}
if err := service.SetMembership(ctx, admin, instanceID, user.ID, "manager"); err != nil {
t.Fatal(err)
}
if err := service.Require(ctx, user, instanceID, authorization.PermissionInstanceRestart); err != nil {
t.Fatal(err)
}
if err := service.SetOverride(ctx, admin, instanceID, user.ID, authorization.PermissionInstanceRestart, "deny"); err != nil {
t.Fatal(err)
}
if err := service.Require(ctx, user, instanceID, authorization.PermissionInstanceRestart); !errors.Is(err, authorization.ErrDenied) {
t.Fatalf("explicit deny error = %v", err)
}
if err := service.SetOverride(ctx, admin, instanceID, user.ID, authorization.PermissionBackupRestore, "allow"); err != nil {
t.Fatal(err)
}
if err := service.Require(ctx, user, instanceID, authorization.PermissionBackupRestore); err != nil {
t.Fatal(err)
}
staleUser := user
staleUser.AuthenticatedAt = time.Now().Add(-authorization.RecentAuthenticationWindow - time.Minute)
if err := service.Require(ctx, staleUser, instanceID, authorization.PermissionBackupRestore); !errors.Is(err, authorization.ErrRecentAuth) {
t.Fatalf("stale user restore error = %v", err)
}
if err := service.Require(ctx, user, "substituted-instance-id", authorization.PermissionInstanceView); !errors.Is(err, authorization.ErrDenied) {
t.Fatalf("substituted ID error = %v", err)
}
if err := service.Require(ctx, admin, instanceID, authorization.PermissionInstanceDelete); err != nil {
t.Fatal(err)
}
stale := admin
stale.AuthenticatedAt = time.Now().Add(-authorization.RecentAuthenticationWindow - time.Minute)
if err := service.Require(ctx, stale, instanceID, authorization.PermissionInstanceDelete); !errors.Is(err, authorization.ErrRecentAuth) {
t.Fatalf("stale admin delete error = %v", err)
}
if err := service.SetMembership(ctx, stale, instanceID, user.ID, "user"); !errors.Is(err, authorization.ErrRecentAuth) {
t.Fatalf("stale admin error = %v", err)
}
}
func TestInstallationRequestReviewNeverDeploys(t *testing.T) {
ctx, db, repository, _, admin, user, _, snapshot := authorizationFixture(t)
service := authorization.New(repository)
request, err := service.CreateInstallationRequest(ctx, user, authorization.RequestInput{
ID: "request-one", TemplateID: snapshot.Template.ID, TemplateVersion: snapshot.Template.Version,
SuggestedName: "Friends server", PlayerEstimate: 8, DesiredSchedule: "Evenings", Message: "Please approve",
})
if err != nil || request.Status != "pending" {
t.Fatalf("request=%#v error=%v", request, err)
}
if _, err := service.CreateInstallationRequest(ctx, user, authorization.RequestInput{ID: "request-two", TemplateID: snapshot.Template.ID, TemplateVersion: snapshot.Template.Version}); !errors.Is(err, authorization.ErrConflict) {
t.Fatalf("duplicate request error = %v", err)
}
userRequests, err := service.ListInstallationRequests(ctx, user)
if err != nil || len(userRequests) != 1 {
t.Fatalf("user requests=%#v error=%v", userRequests, err)
}
reviewed, err := service.ReviewInstallationRequest(ctx, admin, request.ID, "approved", "Capacity available")
if err != nil || reviewed.Status != "approved" || reviewed.SuggestedName != "Friends server" {
t.Fatalf("reviewed=%#v error=%v", reviewed, err)
}
var instanceCount int
if err := db.QueryRow("SELECT COUNT(*) FROM instances").Scan(&instanceCount); err != nil {
t.Fatal(err)
}
if instanceCount != 1 {
t.Fatalf("approval deployed an instance; count=%d", instanceCount)
}
}
func authorizationFixture(t *testing.T) (context.Context, *sql.DB, *sqlite.Repository, *auth.Service, auth.User, auth.User, string, catalog.Snapshot) {
t.Helper()
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
repository := sqlite.NewRepository(db)
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil {
t.Fatal(err)
}
if err := repository.Sync(ctx, snapshots); err != nil {
t.Fatal(err)
}
authService := auth.New(db)
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
adminSession, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
if err != nil {
t.Fatal(err)
}
admin, err := authService.Authenticate(ctx, adminSession.Token)
if err != nil {
t.Fatal(err)
}
createdUser, err := authService.CreateUser(ctx, "player", "another correct battery staple", "user")
if err != nil {
t.Fatal(err)
}
userSession, err := authService.Login(ctx, createdUser.Username, "another correct battery staple", "192.0.2.2:1234")
if err != nil {
t.Fatal(err)
}
user, err := authService.Authenticate(ctx, userSession.Token)
if err != nil {
t.Fatal(err)
}
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{DisplayName: "Family Palworld", Slug: "family-palworld", HostPorts: map[string]int{"game": 38211}, MountPaths: map[string]string{"saved": "/srv/game-servers/family-palworld/saved"}, DataOrigin: "new", BackupRetention: 7})
if err != nil {
t.Fatal(err)
}
instanceID := "abcdefghijklmnopqrstuvwx"
if err := repository.CreateDraft(ctx, instance.Draft{ID: instanceID, Preview: preview}); err != nil {
t.Fatal(err)
}
return ctx, db, repository, authService, admin, user, instanceID, snapshots[0]
}
@@ -0,0 +1,228 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
modernsqlite "modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
)
func (r *Repository) ResolveAccess(ctx context.Context, userID, instanceID string) (authorization.Access, error) {
access := authorization.Access{Overrides: make(map[string]string)}
err := r.db.QueryRowContext(ctx, `SELECT COALESCE(m.role, '') FROM instances i
LEFT JOIN instance_memberships m ON m.instance_id=i.id AND m.user_id=?
WHERE i.id=? AND i.deleted_at IS NULL`, userID, instanceID).Scan(&access.MembershipRole)
if errors.Is(err, sql.ErrNoRows) {
return access, nil
}
if err != nil {
return authorization.Access{}, fmt.Errorf("resolve instance access: %w", err)
}
access.InstanceExists = true
if access.MembershipRole == "" {
return access, nil
}
rows, err := r.db.QueryContext(ctx, `SELECT permission, effect FROM permission_overrides WHERE instance_id=? AND user_id=?`, instanceID, userID)
if err != nil {
return authorization.Access{}, fmt.Errorf("load permission overrides: %w", err)
}
defer rows.Close()
for rows.Next() {
var permission, effect string
if err := rows.Scan(&permission, &effect); err != nil {
return authorization.Access{}, fmt.Errorf("scan permission override: %w", err)
}
access.Overrides[permission] = effect
}
return access, rows.Err()
}
func (r *Repository) SetMembership(ctx context.Context, actorID, instanceID, userID, role string) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin membership update: %w", err)
}
defer func() { _ = tx.Rollback() }()
var globalRole string
err = tx.QueryRowContext(ctx, `SELECT global_role FROM users WHERE id=? AND disabled_at IS NULL`, userID).Scan(&globalRole)
if errors.Is(err, sql.ErrNoRows) {
return authorization.ErrNotFound
}
if err != nil {
return fmt.Errorf("load membership user: %w", err)
}
if globalRole != "user" {
return authorization.ErrInvalidInput
}
var exists int
if err := tx.QueryRowContext(ctx, `SELECT 1 FROM instances WHERE id=? AND deleted_at IS NULL`, instanceID).Scan(&exists); errors.Is(err, sql.ErrNoRows) {
return authorization.ErrNotFound
} else if err != nil {
return fmt.Errorf("load membership instance: %w", err)
}
now := r.now().UTC().Format(time.RFC3339Nano)
_, err = tx.ExecContext(ctx, `INSERT INTO instance_memberships(instance_id, user_id, role, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(instance_id, user_id) DO UPDATE SET role=excluded.role, updated_at=excluded.updated_at`, instanceID, userID, role, actorID, now, now)
if err != nil {
return fmt.Errorf("store instance membership: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit membership update: %w", err)
}
return nil
}
func (r *Repository) DeleteMembership(ctx context.Context, instanceID, userID string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM instance_memberships WHERE instance_id=? AND user_id=?`, instanceID, userID)
if err != nil {
return fmt.Errorf("delete membership: %w", err)
}
changed, _ := result.RowsAffected()
if changed != 1 {
return authorization.ErrNotFound
}
return nil
}
func (r *Repository) SetPermissionOverride(ctx context.Context, actorID, instanceID, userID, permission, effect string) error {
now := r.now().UTC().Format(time.RFC3339Nano)
_, err := r.db.ExecContext(ctx, `INSERT INTO permission_overrides(instance_id, user_id, permission, effect, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(instance_id, user_id, permission) DO UPDATE SET effect=excluded.effect, created_by=excluded.created_by, updated_at=excluded.updated_at`, instanceID, userID, permission, effect, actorID, now, now)
if err != nil {
if isConstraintError(err) {
return authorization.ErrNotFound
}
return fmt.Errorf("store permission override: %w", err)
}
return nil
}
func (r *Repository) DeletePermissionOverride(ctx context.Context, instanceID, userID, permission string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM permission_overrides WHERE instance_id=? AND user_id=? AND permission=?`, instanceID, userID, permission)
if err != nil {
return fmt.Errorf("delete permission override: %w", err)
}
changed, _ := result.RowsAffected()
if changed != 1 {
return authorization.ErrNotFound
}
return nil
}
func (r *Repository) ListMemberships(ctx context.Context, instanceID string) ([]authorization.Membership, error) {
rows, err := r.db.QueryContext(ctx, `SELECT m.instance_id, m.user_id, u.username, m.role, COALESCE(o.permission, ''), COALESCE(o.effect, '')
FROM instance_memberships m JOIN users u ON u.id=m.user_id
LEFT JOIN permission_overrides o ON o.instance_id=m.instance_id AND o.user_id=m.user_id
WHERE m.instance_id=? ORDER BY u.username COLLATE NOCASE, o.permission`, instanceID)
if err != nil {
return nil, fmt.Errorf("list instance memberships: %w", err)
}
defer rows.Close()
var result []authorization.Membership
index := make(map[string]int)
for rows.Next() {
var instanceID, userID, username, role, permission, effect string
if err := rows.Scan(&instanceID, &userID, &username, &role, &permission, &effect); err != nil {
return nil, fmt.Errorf("scan membership: %w", err)
}
position, exists := index[userID]
if !exists {
position = len(result)
index[userID] = position
result = append(result, authorization.Membership{InstanceID: instanceID, UserID: userID, Username: username, Role: role, Overrides: make(map[string]string)})
}
if permission != "" {
result[position].Overrides[permission] = effect
}
}
return result, rows.Err()
}
func (r *Repository) CreateInstallationRequest(ctx context.Context, requesterID string, input authorization.RequestInput) (authorization.InstallationRequest, error) {
now := r.now().UTC().Format(time.RFC3339Nano)
var players any
if input.PlayerEstimate > 0 {
players = input.PlayerEstimate
}
mods := 0
if input.ModsRequested {
mods = 1
}
_, err := r.db.ExecContext(ctx, `INSERT INTO installation_requests(id, requested_by, template_id, template_version, suggested_name, player_estimate, desired_schedule, mods_requested, message, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, input.ID, requesterID, input.TemplateID, input.TemplateVersion, nullable(input.SuggestedName), players, nullable(input.DesiredSchedule), mods, nullable(input.Message), now, now)
if err != nil {
if isConstraintError(err) {
return authorization.InstallationRequest{}, authorization.ErrConflict
}
return authorization.InstallationRequest{}, fmt.Errorf("create installation request: %w", err)
}
requests, err := r.ListInstallationRequests(ctx, requesterID, false)
if err != nil {
return authorization.InstallationRequest{}, err
}
for _, request := range requests {
if request.ID == input.ID {
return request, nil
}
}
return authorization.InstallationRequest{}, authorization.ErrNotFound
}
func (r *Repository) ListInstallationRequests(ctx context.Context, userID string, all bool) ([]authorization.InstallationRequest, error) {
query := `SELECT q.id, q.requested_by, requester.username, q.template_id, q.template_version, COALESCE(q.suggested_name, ''), COALESCE(q.player_estimate, 0), COALESCE(q.desired_schedule, ''), q.mods_requested, COALESCE(q.message, ''), q.status, COALESCE(q.reviewed_by, ''), COALESCE(reviewer.username, ''), COALESCE(q.review_reason, ''), q.created_at, COALESCE(q.reviewed_at, '')
FROM installation_requests q JOIN users requester ON requester.id=q.requested_by LEFT JOIN users reviewer ON reviewer.id=q.reviewed_by`
args := []any{}
if !all {
query += ` WHERE q.requested_by=?`
args = append(args, userID)
}
query += ` ORDER BY q.created_at DESC, q.id`
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list installation requests: %w", err)
}
defer rows.Close()
var result []authorization.InstallationRequest
for rows.Next() {
var request authorization.InstallationRequest
var mods int
if err := rows.Scan(&request.ID, &request.RequestedBy, &request.RequesterName, &request.TemplateID, &request.TemplateVersion, &request.SuggestedName, &request.PlayerEstimate, &request.DesiredSchedule, &mods, &request.Message, &request.Status, &request.ReviewedBy, &request.ReviewerName, &request.ReviewReason, &request.CreatedAt, &request.ReviewedAt); err != nil {
return nil, fmt.Errorf("scan installation request: %w", err)
}
request.ModsRequested = mods != 0
result = append(result, request)
}
return result, rows.Err()
}
func (r *Repository) ReviewInstallationRequest(ctx context.Context, reviewerID, requestID, decision, reason string) (authorization.InstallationRequest, error) {
now := r.now().UTC().Format(time.RFC3339Nano)
result, err := r.db.ExecContext(ctx, `UPDATE installation_requests SET status=?, reviewed_by=?, review_reason=?, reviewed_at=?, updated_at=? WHERE id=? AND status='pending'`, decision, reviewerID, nullable(reason), now, now, requestID)
if err != nil {
return authorization.InstallationRequest{}, fmt.Errorf("review installation request: %w", err)
}
changed, _ := result.RowsAffected()
if changed != 1 {
return authorization.InstallationRequest{}, authorization.ErrConflict
}
requests, err := r.ListInstallationRequests(ctx, reviewerID, true)
if err != nil {
return authorization.InstallationRequest{}, err
}
for _, request := range requests {
if request.ID == requestID {
return request, nil
}
}
return authorization.InstallationRequest{}, authorization.ErrNotFound
}
func isConstraintError(err error) bool {
var sqliteError *modernsqlite.Error
return errors.As(err, &sqliteError) && sqliteError.Code()&0xff == sqlite3.SQLITE_CONSTRAINT
}
+13 -4
View File
@@ -24,8 +24,17 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 3 {
t.Fatalf("got %d migrations, want 3", count)
if count != 4 {
t.Fatalf("got %d migrations, want 4", count)
}
for _, table := range []string{"instance_memberships", "permission_overrides", "installation_requests"} {
var found int
if err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&found); err != nil {
t.Fatal(err)
}
if found != 1 {
t.Fatalf("authorization table %q is missing", table)
}
}
var foreignKeys, busyTimeout int
var journalMode string
@@ -53,8 +62,8 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 3 {
t.Fatalf("reopened database has %d migrations, want 3", count)
if count != 4 {
t.Fatalf("reopened database has %d migrations, want 4", count)
}
}
+268 -13
View File
@@ -17,6 +17,7 @@ import (
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
)
@@ -52,17 +53,19 @@ var englishMessages = map[string]string{
}
type server struct {
auth *auth.Service
templates *template.Template
logger *slog.Logger
repository repository
lifecycle *instance.LifecycleService
auth *auth.Service
templates *template.Template
logger *slog.Logger
repository repository
lifecycle *instance.LifecycleService
permissions *authorization.Service
}
type repository interface {
catalog.Repository
instance.Repository
instance.LifecycleRepository
authorization.Repository
}
type pageData struct {
@@ -94,11 +97,24 @@ func newHandler(authService *auth.Service, repository repository, lifecycle *ins
return nil, err
}
s := &server{auth: authService, templates: templates, logger: logger, repository: repository, lifecycle: lifecycle}
if repository != nil {
s.permissions = authorization.New(repository)
}
mux := http.NewServeMux()
if repository != nil {
mux.HandleFunc("GET /api/v1/catalog", s.catalogList)
mux.HandleFunc("POST /api/v1/instances/preview", s.instancePreview)
mux.HandleFunc("POST /api/v1/instances/drafts", s.instanceDraft)
mux.HandleFunc("GET /api/v1/installation-requests", s.installationRequestList)
mux.HandleFunc("POST /api/v1/installation-requests", s.installationRequestCreate)
mux.HandleFunc("POST /api/v1/installation-requests/{id}/review", s.installationRequestReview)
mux.HandleFunc("GET /api/v1/admin/users", s.userList)
mux.HandleFunc("POST /api/v1/admin/users", s.userCreate)
mux.HandleFunc("GET /api/v1/instances/{id}/memberships", s.membershipList)
mux.HandleFunc("PUT /api/v1/instances/{id}/memberships/{userID}", s.membershipSet)
mux.HandleFunc("DELETE /api/v1/instances/{id}/memberships/{userID}", s.membershipDelete)
mux.HandleFunc("PUT /api/v1/instances/{id}/memberships/{userID}/permissions/{permission}", s.permissionOverrideSet)
mux.HandleFunc("DELETE /api/v1/instances/{id}/memberships/{userID}/permissions/{permission}", s.permissionOverrideDelete)
if lifecycle != nil {
mux.HandleFunc("GET /api/v1/instances/{id}", s.instanceInspect)
mux.HandleFunc("GET /api/v1/instances/{id}/stats", s.instanceStats)
@@ -168,7 +184,7 @@ func (s *server) instanceDraft(w http.ResponseWriter, r *http.Request) {
}
func (s *server) instanceInspect(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceView); !ok {
return
}
result, err := s.lifecycle.Inspect(r.Context(), r.PathValue("id"))
@@ -180,7 +196,7 @@ func (s *server) instanceInspect(w http.ResponseWriter, r *http.Request) {
}
func (s *server) instanceStats(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionMetricsView); !ok {
return
}
stats, err := s.lifecycle.Stats(r.Context(), r.PathValue("id"))
@@ -192,25 +208,36 @@ func (s *server) instanceStats(w http.ResponseWriter, r *http.Request) {
}
func (s *server) instanceInstall(w http.ResponseWriter, r *http.Request) {
s.lifecycleAction(w, r, s.lifecycle.Install)
s.lifecycleAdminAction(w, r, s.lifecycle.Install)
}
func (s *server) instanceStart(w http.ResponseWriter, r *http.Request) {
s.lifecycleAction(w, r, s.lifecycle.Start)
s.lifecycleAction(w, r, authorization.PermissionInstanceStart, s.lifecycle.Start)
}
func (s *server) instanceStop(w http.ResponseWriter, r *http.Request) {
s.lifecycleAction(w, r, s.lifecycle.Stop)
s.lifecycleAction(w, r, authorization.PermissionInstanceStop, s.lifecycle.Stop)
}
func (s *server) instanceRestart(w http.ResponseWriter, r *http.Request) {
s.lifecycleAction(w, r, s.lifecycle.Restart)
s.lifecycleAction(w, r, authorization.PermissionInstanceRestart, s.lifecycle.Restart)
}
func (s *server) lifecycleAction(w http.ResponseWriter, r *http.Request, action func(context.Context, string) (instance.OperationResult, error)) {
func (s *server) lifecycleAction(w http.ResponseWriter, r *http.Request, permission string, action func(context.Context, string) (instance.OperationResult, error)) {
if _, ok := s.requireInstancePermission(w, r, permission); !ok {
return
}
s.runLifecycleAction(w, r, action)
}
func (s *server) lifecycleAdminAction(w http.ResponseWriter, r *http.Request, action func(context.Context, string) (instance.OperationResult, error)) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return
}
s.runLifecycleAction(w, r, action)
}
func (s *server) runLifecycleAction(w http.ResponseWriter, r *http.Request, action func(context.Context, string) (instance.OperationResult, error)) {
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
body, err := io.ReadAll(r.Body)
@@ -228,7 +255,7 @@ func (s *server) lifecycleAction(w http.ResponseWriter, r *http.Request, action
}
func (s *server) instanceDeleteContainer(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceDelete); !ok {
return
}
var request struct {
@@ -295,6 +322,234 @@ func (s *server) buildAPIPreview(w http.ResponseWriter, r *http.Request) (previe
return request, preview, true
}
func (s *server) requireInstancePermission(w http.ResponseWriter, r *http.Request, permission string) (auth.User, bool) {
user, ok := s.requireAPIUser(w, r, false)
if !ok {
return auth.User{}, false
}
if err := s.permissions.Require(r.Context(), user, r.PathValue("id"), permission); err != nil {
s.authorizationProblem(w, err)
return auth.User{}, false
}
return user, true
}
func (s *server) userList(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return
}
users, err := s.auth.ListUsers(r.Context())
if err != nil {
s.apiProblem(w, http.StatusInternalServerError, "users_unavailable", "The users are unavailable.")
return
}
s.apiJSON(w, http.StatusOK, map[string]any{"users": users})
}
func (s *server) userCreate(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, true)
if !ok {
return
}
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
s.authorizationProblem(w, err)
return
}
var request struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}
if !s.decodeAPIJSON(w, r, &request) {
return
}
user, err := s.auth.CreateUser(r.Context(), request.Username, request.Password, request.Role)
if err != nil {
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_user", "The user could not be created.")
return
}
s.apiJSON(w, http.StatusCreated, user)
}
func (s *server) membershipList(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, true)
if !ok {
return
}
memberships, err := s.permissions.ListMemberships(r.Context(), actor, r.PathValue("id"))
if err != nil {
s.authorizationProblem(w, err)
return
}
s.apiJSON(w, http.StatusOK, map[string]any{"memberships": memberships})
}
func (s *server) membershipSet(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, true)
if !ok {
return
}
var request struct {
Role string `json:"role"`
}
if !s.decodeAPIJSON(w, r, &request) {
return
}
err := s.permissions.SetMembership(r.Context(), actor, r.PathValue("id"), r.PathValue("userID"), request.Role)
if err != nil {
s.authorizationProblem(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *server) membershipDelete(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, true)
if !ok || !s.requireEmptyBody(w, r) {
return
}
if err := s.permissions.DeleteMembership(r.Context(), actor, r.PathValue("id"), r.PathValue("userID")); err != nil {
s.authorizationProblem(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *server) permissionOverrideSet(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, true)
if !ok {
return
}
var request struct {
Effect string `json:"effect"`
}
if !s.decodeAPIJSON(w, r, &request) {
return
}
err := s.permissions.SetOverride(r.Context(), actor, r.PathValue("id"), r.PathValue("userID"), r.PathValue("permission"), request.Effect)
if err != nil {
s.authorizationProblem(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *server) permissionOverrideDelete(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, true)
if !ok || !s.requireEmptyBody(w, r) {
return
}
if err := s.permissions.DeleteOverride(r.Context(), actor, r.PathValue("id"), r.PathValue("userID"), r.PathValue("permission")); err != nil {
s.authorizationProblem(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *server) installationRequestCreate(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, false)
if !ok {
return
}
var request struct {
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
SuggestedName string `json:"suggested_name"`
PlayerEstimate int `json:"player_estimate"`
DesiredSchedule string `json:"desired_schedule"`
ModsRequested bool `json:"mods_requested"`
Message string `json:"message"`
}
if !s.decodeAPIJSON(w, r, &request) {
return
}
created, err := s.permissions.CreateInstallationRequest(r.Context(), actor, authorization.RequestInput{
ID: randomToken(), TemplateID: request.TemplateID, TemplateVersion: request.TemplateVersion,
SuggestedName: request.SuggestedName, PlayerEstimate: request.PlayerEstimate,
DesiredSchedule: request.DesiredSchedule, ModsRequested: request.ModsRequested, Message: request.Message,
})
if err != nil {
s.authorizationProblem(w, err)
return
}
s.apiJSON(w, http.StatusCreated, created)
}
func (s *server) installationRequestList(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, false)
if !ok {
return
}
requests, err := s.permissions.ListInstallationRequests(r.Context(), actor)
if err != nil {
s.authorizationProblem(w, err)
return
}
s.apiJSON(w, http.StatusOK, map[string]any{"requests": requests})
}
func (s *server) installationRequestReview(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, true)
if !ok {
return
}
var request struct {
Decision string `json:"decision"`
Reason string `json:"reason"`
}
if !s.decodeAPIJSON(w, r, &request) {
return
}
reviewed, err := s.permissions.ReviewInstallationRequest(r.Context(), actor, r.PathValue("id"), request.Decision, request.Reason)
if err != nil {
s.authorizationProblem(w, err)
return
}
s.apiJSON(w, http.StatusOK, reviewed)
}
func (s *server) decodeAPIJSON(w http.ResponseWriter, r *http.Request, target any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
s.apiProblem(w, http.StatusBadRequest, "invalid_request", "The request is invalid.")
return false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
s.apiProblem(w, http.StatusBadRequest, "invalid_request", "The request is invalid.")
return false
}
return true
}
func (s *server) requireEmptyBody(w http.ResponseWriter, r *http.Request) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
body, err := io.ReadAll(r.Body)
if err != nil || len(bytes.TrimSpace(body)) != 0 {
s.apiProblem(w, http.StatusBadRequest, "invalid_request", "The request is invalid.")
return false
}
return true
}
func (s *server) authorizationProblem(w http.ResponseWriter, err error) {
status, code := http.StatusInternalServerError, "authorization_failed"
switch {
case errors.Is(err, authorization.ErrDenied):
status, code = http.StatusForbidden, "permission_denied"
case errors.Is(err, authorization.ErrRecentAuth):
status, code = http.StatusForbidden, "reauthentication_required"
case errors.Is(err, authorization.ErrInvalidInput):
status, code = http.StatusUnprocessableEntity, "invalid_request"
case errors.Is(err, authorization.ErrNotFound):
status, code = http.StatusNotFound, "not_found"
case errors.Is(err, authorization.ErrConflict):
status, code = http.StatusConflict, "conflict"
}
s.apiProblem(w, status, code, "The authorization request could not be completed.")
}
func (s *server) requireAPIUser(w http.ResponseWriter, r *http.Request, admin bool) (auth.User, bool) {
user, err := s.currentUser(r)
if err != nil {
+114 -1
View File
@@ -120,6 +120,115 @@ func TestCatalogPreviewAndDraftAPIAuthorization(t *testing.T) {
assertStatus(t, unsafeResponse, http.StatusUnprocessableEntity)
}
func TestInstanceAuthorizationAndInstallationRequestWorkflow(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
repository := sqlite.NewRepository(db)
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil {
t.Fatal(err)
}
if err := repository.Sync(ctx, snapshots); err != nil {
t.Fatal(err)
}
authService := auth.New(db)
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
player, err := authService.CreateUser(ctx, "player", "another correct battery staple", "user")
if err != nil {
t.Fatal(err)
}
adminSession, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
if err != nil {
t.Fatal(err)
}
playerSession, err := authService.Login(ctx, "player", "another correct battery staple", "192.0.2.2:1234")
if err != nil {
t.Fatal(err)
}
handler, err := NewHandlerWithLifecycle(authService, repository, webLifecycleAgent{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
adminCookie := &http.Cookie{Name: sessionCookie, Value: adminSession.Token}
playerCookie := &http.Cookie{Name: sessionCookie, Value: playerSession.Token}
draftPayload, _ := json.Marshal(map[string]any{
"template_id": "palworld-official", "template_version": "1.0.0",
"display_name": "Authorization Test", "slug": "authorization-test",
"host_ports": map[string]int{"game": 8211},
"mount_paths": map[string]string{"saved": "/srv/game-servers/authorization-test/saved"},
"data_origin": "new", "backup_retention": 7,
})
draft := jsonRequest(t, handler, "/api/v1/instances/drafts", draftPayload, adminCookie, adminSession.CSRFToken)
assertStatus(t, draft, http.StatusCreated)
var draftResult map[string]string
if err := json.Unmarshal(draft.Body.Bytes(), &draftResult); err != nil {
t.Fatal(err)
}
instanceID := draftResult["id"]
install := jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/install", nil, adminCookie, adminSession.CSRFToken)
assertStatus(t, install, http.StatusOK)
denied := request(t, handler, http.MethodGet, "/api/v1/instances/"+instanceID, []*http.Cookie{playerCookie})
assertStatus(t, denied, http.StatusForbidden)
membershipPath := "/api/v1/instances/" + instanceID + "/memberships/" + player.ID
membership := jsonMethodRequest(t, handler, http.MethodPut, membershipPath, []byte(`{"role":"user"}`), adminCookie, adminSession.CSRFToken)
assertStatus(t, membership, http.StatusNoContent)
inspect := request(t, handler, http.MethodGet, "/api/v1/instances/"+instanceID, []*http.Cookie{playerCookie})
assertStatus(t, inspect, http.StatusOK)
start := jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/start", nil, playerCookie, playerSession.CSRFToken)
assertStatus(t, start, http.StatusOK)
restart := jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/restart", nil, playerCookie, playerSession.CSRFToken)
assertStatus(t, restart, http.StatusForbidden)
membership = jsonMethodRequest(t, handler, http.MethodPut, membershipPath, []byte(`{"role":"manager"}`), adminCookie, adminSession.CSRFToken)
assertStatus(t, membership, http.StatusNoContent)
restart = jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/restart", nil, playerCookie, playerSession.CSRFToken)
assertStatus(t, restart, http.StatusOK)
overridePath := membershipPath + "/permissions/instance.restart"
override := jsonMethodRequest(t, handler, http.MethodPut, overridePath, []byte(`{"effect":"deny"}`), adminCookie, adminSession.CSRFToken)
assertStatus(t, override, http.StatusNoContent)
restart = jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/restart", nil, playerCookie, playerSession.CSRFToken)
assertStatus(t, restart, http.StatusForbidden)
substitution := request(t, handler, http.MethodGet, "/api/v1/instances/not-the-member-instance", []*http.Cookie{playerCookie})
assertStatus(t, substitution, http.StatusForbidden)
requestPayload := []byte(`{"template_id":"palworld-official","template_version":"1.0.0","suggested_name":"Friends","player_estimate":8,"desired_schedule":"evenings","mods_requested":true,"message":"Private group"}`)
installationRequest := jsonRequest(t, handler, "/api/v1/installation-requests", requestPayload, playerCookie, playerSession.CSRFToken)
assertStatus(t, installationRequest, http.StatusCreated)
var createdRequest struct {
ID string `json:"id"`
}
if err := json.Unmarshal(installationRequest.Body.Bytes(), &createdRequest); err != nil {
t.Fatal(err)
}
duplicate := jsonRequest(t, handler, "/api/v1/installation-requests", requestPayload, playerCookie, playerSession.CSRFToken)
assertStatus(t, duplicate, http.StatusConflict)
playerList := request(t, handler, http.MethodGet, "/api/v1/installation-requests", []*http.Cookie{playerCookie})
assertStatus(t, playerList, http.StatusOK)
if !strings.Contains(playerList.Body.String(), createdRequest.ID) {
t.Fatalf("request list = %s", playerList.Body.String())
}
var instancesBefore int
if err := db.QueryRow("SELECT COUNT(*) FROM instances").Scan(&instancesBefore); err != nil {
t.Fatal(err)
}
review := jsonRequest(t, handler, "/api/v1/installation-requests/"+createdRequest.ID+"/review", []byte(`{"decision":"approved","reason":"capacity available"}`), adminCookie, adminSession.CSRFToken)
assertStatus(t, review, http.StatusOK)
var instancesAfter int
if err := db.QueryRow("SELECT COUNT(*) FROM instances").Scan(&instancesAfter); err != nil {
t.Fatal(err)
}
if instancesAfter != instancesBefore {
t.Fatalf("approval deployed an instance: before=%d after=%d", instancesBefore, instancesAfter)
}
}
func TestBootstrapAuthenticationAndLogoutFlow(t *testing.T) {
handler := testHandler(t)
@@ -269,8 +378,12 @@ func formRequest(t *testing.T, handler http.Handler, target string, values url.V
}
func jsonRequest(t *testing.T, handler http.Handler, target string, body []byte, session *http.Cookie, csrf string) *httptest.ResponseRecorder {
return jsonMethodRequest(t, handler, http.MethodPost, target, body, session, csrf)
}
func jsonMethodRequest(t *testing.T, handler http.Handler, method, target string, body []byte, session *http.Cookie, csrf string) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body))
request := httptest.NewRequest(method, target, bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-CSRF-Token", csrf)
request.AddCookie(session)
@@ -0,0 +1,46 @@
CREATE TABLE instance_memberships (
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'manager')),
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (instance_id, user_id)
);
CREATE INDEX instance_memberships_user_idx ON instance_memberships(user_id, instance_id);
CREATE TABLE permission_overrides (
instance_id TEXT NOT NULL,
user_id TEXT NOT NULL,
permission TEXT NOT NULL CHECK (permission IN ('instance.view', 'instance.start', 'instance.stop', 'instance.restart', 'instance.update', 'instance.configure', 'instance.delete', 'instance.welcome.edit', 'metrics.view', 'players.view', 'players.kick', 'players.ban', 'players.unban', 'announcements.send', 'logs.view', 'mods.manage', 'backup.create', 'backup.list', 'backup.export', 'backup.restore', 'backup.delete', 'request.create')),
effect TEXT NOT NULL CHECK (effect IN ('allow', 'deny')),
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (instance_id, user_id, permission),
FOREIGN KEY (instance_id, user_id) REFERENCES instance_memberships(instance_id, user_id) ON DELETE CASCADE
);
CREATE TABLE installation_requests (
id TEXT PRIMARY KEY,
requested_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
template_id TEXT NOT NULL,
template_version TEXT NOT NULL,
suggested_name TEXT,
player_estimate INTEGER CHECK (player_estimate IS NULL OR (player_estimate >= 1 AND player_estimate <= 10000)),
desired_schedule TEXT,
mods_requested INTEGER NOT NULL DEFAULT 0 CHECK (mods_requested IN (0, 1)),
message TEXT,
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'refused', 'cancelled')),
reviewed_by TEXT REFERENCES users(id) ON DELETE RESTRICT,
review_reason TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
reviewed_at TEXT,
FOREIGN KEY (template_id, template_version) REFERENCES template_versions(template_id, version) ON DELETE RESTRICT
);
CREATE UNIQUE INDEX installation_requests_pending_idx ON installation_requests(requested_by, template_id, template_version) WHERE status = 'pending';
CREATE INDEX installation_requests_status_idx ON installation_requests(status, created_at);
CREATE INDEX installation_requests_requester_idx ON installation_requests(requested_by, created_at DESC);