Merge pull request 'Instance authorization foundation' (#5) from codex/instance-authorization-foundation into main
Reviewed-on: #5 Reviewed-by: tony <1+tony@noreply.localhost>
This commit was merged in pull request #5.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
Reference in New Issue
Block a user