# Kubestro Permission Model

## Purpose and scope

This document describes the permission model for Kubestro: how users, groups, roles, and permissions relate, how modules contribute their own permissions to the system without core changes, how an access decision is evaluated, and how agents and modules prove who they are. It is the reference the tracker issues point at; where an issue and this document disagree, this document wins.

## Terminology

These words are used precisely throughout, and until now were defined nowhere:

- **Control plane** — the central server: the HTTP API and the dashboard it serves.
- **Module** — a plugin that lives beside the control-plane API and extends its capabilities, in the shape of Go's plugin-RPC system. A module is *installed* on the control-plane host. Modules own object types and declare permissions.
- **Agent** — a remote node the control plane controls. An agent executes commands the control plane hands it; it never decides what a user is allowed to do.
- **Runtime** — an agent-side extension that knows how to deploy one kind of server (Minecraft, Factorio, Satisfactory) along with its resources (worlds, plugins, mods). A runtime reports what it can deploy; it does not declare permissions.
- **Object** — anything a grant can be scoped to: a game server, and later a folder. An object has an owner and belongs to an object type registered by a module.
- **Subject** — anything a grant can be given to: a user or a group. Agents, modules and runtimes are never subjects.

## Core concepts

**Permission.** A string identifier of the form `namespace.resource.action`, for example `core.user.create` or `minecraft.server.restart`. The namespace identifies the origin — `core` for the control plane itself, or a module's name. Permissions live in a shared catalog and are never invented ad hoc by a client; they must be registered first.

The key is exactly three segments, each matching `[a-z0-9_-]+`. A domain that feels like it needs a fourth segment gets a new resource name instead (`minecraft.serverfile.read`, not `minecraft.server.file.read`). Fixed arity keeps the key parseable, indexable, and renderable as a two-level tree in the role editor.

**Catalog.** The registry of every permission that exists, with its description and the object types it can be scoped to. Core's own permissions are declared in code and upserted at boot; a module's are registered by the module when it connects.

**Role.** A named, reusable set of permissions. A role may include exact permission strings or wildcards (`*` for everything, `minecraft.*` for a namespace, `minecraft.server.*` for a resource). Wildcards are a role-level concept only, and they are allow-only — see *Deny, and what it costs*.

**Group.** A named collection of users. A group has no permissions of its own; it exists purely as a reusable target for grants, so access can be given to "the Ops team" instead of to each member individually. Membership confers nothing automatically — it only makes the group's grants apply to that member. Groups are flat: a group contains users, never other groups. A user may belong to any number of groups.

**Grant.** The assignment of access to a subject. A grant carries four things:

| Part | Values |
|---|---|
| Subject | a user, or a group |
| Content | a reference to a Role, or an inline list of literal permission keys |
| Scope | global (platform-wide), or one specific object |
| Effect | allow, or deny |

**Absent is not deny.** A permission a subject was never granted is simply absent, and absence is resolved by the default-deny floor at the end of evaluation. A deny is a distinct, deliberate statement that outranks allows at the same scope. Conflating the two is the most common source of confusion in every system that offers three states, so the interface must always show three: allowed, denied, not set.

## Granting a subset of permissions on an object

An owner sharing access to an object (for example, their game server) has two ways to do it:

- **Reference an existing Role.** The owner picks a predefined, reusable role such as "Server Operator". Simple, and the grant stays meaningful as a named thing when reviewed later.
- **Grant an inline, one-off subset directly.** The owner hand-picks individual permissions for this specific grant, with no role involved — the same relationship AWS IAM has between a managed policy and an inline policy. This exists because requiring a role to already exist for every combination an owner might want is too restrictive; an owner should not need an administrator to define a new role just to share "view console only" with one person.

The trade-off is intentional: role-based grants are reusable and easier to audit ("what does the Server Operator role include"), while inline grants are more flexible but don't compose — if an owner wants to share the same one-off combination with ten people, they repeat it ten times rather than defining it once. Both paths write to the same grant record; it carries either a role reference or a literal permission list, never both.

For an owner's sharing screen to offer the right checkboxes, each permission in the catalog is tagged with the object types it applies to (for example, `minecraft.server.restart` applies to objects of type `server`), so the sharing screen shows only the permissions relevant to the object being shared.

## How access is decided

Access for "can subject S perform permission P on object O" (O may be absent for platform-wide actions) is decided by walking scopes from the most specific to the least specific. The first scope that has anything to say decides; within a scope, entries addressed to the subject directly outrank entries addressed to a group they belong to, and a deny outranks an allow.

```
if S holds a global role containing a wildcard that matches P:
    return ALLOW                                   # administrator short-circuit

for scope in [O, ancestors of O nearest to root, global]:
    for kind in [grants addressed to S, grants addressed to S's groups]:
        entries = grants at `scope` of `kind` whose permissions match P
        if entries is not empty:
            return DENY if any entry has effect=deny else ALLOW

return DENY                                        # default deny
```

```mermaid
flowchart TD
    Start([Can S do P on O?]) --> Admin{S holds a global<br/>wildcard role?}
    Admin -- yes --> Allow([Allow])
    Admin -- no --> Loop[Next scope:<br/>O, then each ancestor,<br/>then global]
    Loop --> User{Entries addressed<br/>to S at this scope?}
    User -- yes --> UD{Any of them<br/>a deny?}
    UD -- yes --> Deny([Deny])
    UD -- no --> Allow
    User -- no --> Group{Entries addressed<br/>to S's groups<br/>at this scope?}
    Group -- yes --> GD{Any of them<br/>a deny?}
    GD -- yes --> Deny
    GD -- no --> Allow
    Group -- no --> More{Scopes left?}
    More -- yes --> Loop
    More -- no --> Deny
```

Three consequences worth stating explicitly, because they are what people get wrong:

- **A grant closer to the object wins over one further away.** An owner can deny a specific person on their own server even though the enclosing folder allowed the whole team — and, symmetrically, an owner can allow something the folder never granted. Access to an object is at the discretion of that object's owner.
- **Within one scope, a deny wins.** If one group a user belongs to allows and another denies at the same scope, the answer is deny. To re-admit that user, grant them directly at that scope (user-addressed entries are checked before group-addressed ones) or at a more specific one.
- **A scope that says nothing is skipped.** Evaluation only stops at a scope that has a matching entry; empty scopes fall through to the next, and falling off the end is a deny.

This is the NTFS/Discord family of rules — nearest scope wins, deny before allow inside a scope — rather than the AWS family, where a single deny anywhere is final. The AWS shape is a *ceiling* imposed by someone other than the grantor, and a ceiling an object owner can override is not a ceiling. Kubestro does not have that need today; if a non-overridable ceiling is ever wanted, it is added as a separate, named layer above this one, never by changing what deny means here.

## Deny, and what it costs

Deny is the part of this model that carries ongoing cost, so the constraints on it are deliberate:

- **A deny names literal permission keys. No wildcards.** A wildcard deny means "everything in this namespace, including whatever the next version of that module adds" — the security posture becomes a function of a module's future release notes. This is precisely why Kubernetes refused deny rules outright. Allow-side wildcards keep their convenience because widening an allow is a decision an administrator makes about permissions they can see today, and the administrator short-circuit is explicit.
- **A deny must be explainable.** Every system in this family that shipped deny without an explain facility regretted it: the top complaint about Discord's overwrites is that there is no way to see which overwrite blocked access, and AWS had to build a policy simulator. Kubestro therefore treats explanation as part of the feature, not a later nicety: a check can return the winning scope, subject and grant, not just a boolean.
- **A deny must not make "who can act on this object" unanswerable.** Reverse lookups — who has access here, and through what — stay a supported query, and the schema is shaped so they remain a bounded query rather than a per-user recheck.
- **The administrator short-circuit runs first.** Without it, one bad deny on a folder can lock every administrator out of their own installation.

## Ownership is explicit, not automatic

Creating an object does not grant access to anyone but its creator and platform administrators (via a wildcard role). Sharing is always an explicit grant, to a user or to a group. This was chosen over automatic inheritance ("everyone in the creator's group gets access") because it keeps the answer to "who can access this and why" always traceable to a grant record, with no implicit rule to also remember.

## Global roles vs object-scoped roles

A global role and an object-scoped role are the same underlying concept: a name plus a set of permissions. The only difference is where an instance may be assigned, which a `scope` field (`global`, `object`, or `both`) captures. There is one Role table, not two. A role intended for platform-wide use (Administrator) is not assignable as an object grant, and a role intended for object use (Server Operator) is not assignable globally; this is validated at assignment time and reflected in how role lists are filtered in the interface.

## Containers and inheritance

Folders do not exist yet, and this epic does not build them. What it does build is the scope abstraction they need: evaluation already walks an ancestor chain, grants already carry a scope, and the resolver is tested against container scopes. The folders feature, when it arrives, supplies the ancestry and changes nothing about how a decision is reached.

What is already decided about that future shape:

- Folders nest to arbitrary depth, the same shape as GitLab's groups and subgroups, and a folder is a core concept rather than a module's, since it organizes objects regardless of which module created them.
- Inheritance is not a separate mechanism: a grant on a folder is an entry at that scope in the same walk, and because nearer scopes decide first, a lower level can both broaden and narrow what an upper level said.
- Folder management is permission-gated like anything else (`core.folder.create`, `core.folder.manage`).
- Deleting a folder that still contains servers or subfolders is rejected; the contents must be moved or deleted first. No cascade. This trades a little friction for ruling out "I didn't realize that folder still had three live servers in it".

Because a check walks an ancestor chain of unknown depth, the ancestry lookup sits behind its own port. The first implementation can walk parent links directly; a materialized path (Postgres `ltree` with a GiST index) is the intended replacement once folders exist, since checks vastly outnumber folder moves and `ltree` turns "every object under this folder" into one indexed predicate. Swapping one for the other must not touch a use case.

## Guards

Three rules exist to stop the permission system from being used to defeat itself:

- **You can only grant what you hold.** Assigning a role or an inline permission set requires `core.grant.manage` *and* holding every permission being granted. Without this second half, "can manage grants" silently equals "can become an administrator" — the exact bug Pterodactyl shipped and had to fix. Holding a matching wildcard satisfies the check. A refusal names the permission that was missing.
- **The last administrator cannot be removed.** The final holder of a global wildcard role cannot be deleted, deactivated, or stripped of that role; the request is refused with a clear message rather than leaving an installation no one can administer.
- **Seeded roles.** A protected `Administrator` role (wildcard, not editable or deletable) exists from first install and is assigned to the account created through the first-install setup screen. Editable `Viewer` and `Operator` starter roles ship alongside it and can be changed or deleted freely.

## User lifecycle

Accounts are created through first-install setup, an emailed invitation, or an administrator creating one directly — all covered by the account-provisioning feature, which builds on this model rather than the other way round.

Beyond creation, an administrator can edit a user, suspend and reactivate them, and delete them:

- **Suspension** is reversible and immediate: the account cannot authenticate and existing sessions stop working, but nothing is lost.
- **Deletion** is staged. The account is suspended for a grace period, after which it is hard-deleted and its references in recorded history are anonymized. The grace period exists so a deletion made in error can be undone.
- **Owned objects block deletion.** If the user still owns objects, the request warns and requires those objects to be transferred or deleted first — the same rule as a non-empty folder, for the same reason.

## Agents, modules and runtimes

Authorization is centralized. The control plane decides, and nothing else does:

- An **agent** authenticates so the control plane knows it is talking to a node it enrolled; it never learns what a human is allowed to do. Kubernetes RBAC never runs on the kubelet, and this is the same split.
- A **runtime** is reached through its agent and inherits that trust. It declares what it can deploy, not what anyone may do.
- A **module** is installed on the control-plane host and trusted at the level of any other software the operator chose to install.

**Agent enrollment.** An administrator generates a short-lived, single-use bootstrap token. The agent presents it on first connect; the control plane registers the agent and issues it a long-lived credential it uses from then on. Credentials are listable, rotatable and revocable per agent, so losing one node does not mean re-enrolling the fleet.

**Module identity.** A module gets its identity from being installed, not from enrollment — there is no token to copy. The RPC channel between control plane and module is protected by a handshake (protocol version plus a shared magic cookie, so an unrelated process cannot be mistaken for a module) and by mTLS with certificates minted per process start. Nothing long-lived is stored that could leak.

**Module service account.** Each module has a service account that appears as the actor on anything the module does on its own initiative, such as a scheduled task, so recorded history says "the Minecraft module restarted this server" rather than attributing it to nobody. The service account is an attribution label only: it is not a subject in the grant model, and a module's own actions are not permission-checked. This follows from a module already being trusted at install time — the same assumption that lets it register permissions without review. It should be revisited only if Kubestro ever supports modules the operator did not choose, such as a marketplace.

**Permission registration.** Only modules register permissions, and only under their own namespace.

```mermaid
sequenceDiagram
    participant Mod as Module
    participant CP as Control plane
    participant DB as Permission catalog
    participant UI as Dashboard role editor
    Mod->>CP: Handshake, then register (namespace, keys, descriptions, object types)
    CP->>DB: Upsert into the catalog
    UI->>DB: Read the catalog to populate the role editor
    Note over Mod,CP: Later — normal operation
    CP->>Mod: Work to do, already authorized
```

A runtime reports its deployable server types and resource kinds to its agent, and the module that fronts that game type maps them onto its own permission keys. This keeps one registration path into the catalog, so a namespace never depends on a node that may disconnect.

## Module uninstallation

Uninstalling a module is destructive across three stores, only two of which belong to the permission system:

1. The module's own domain data (servers, worlds, backups) — owned and deleted by the module itself.
2. Grants referencing objects of that module's registered types — cleaned up by object type rather than by enumerating objects.
3. Catalog entries under that module's namespace — removed once nothing can reference them meaningfully.

A synchronous pre-uninstall hook gives the module a last chance to object or finish work in progress; an asynchronous event, fired once the module confirms its own data is gone, triggers the control plane's cleanup.

```mermaid
sequenceDiagram
    participant Admin
    participant CP as Control plane
    participant Mod as Module
    participant DB as Postgres

    Admin->>CP: Request uninstall
    CP->>DB: Compute preview — affected objects, grants, roles
    CP-->>Admin: Show preview, require typing the module name
    Admin->>CP: Confirm
    CP->>Mod: PreUninstall hook
    Mod-->>CP: Ack
    Mod->>Mod: Delete its own domain data
    Mod->>CP: ModuleUninstalled event
    CP->>DB: Delete grants for that namespace's object types
    CP->>DB: Delete catalog entries for the namespace
    CP->>DB: Strip those keys from any role that used them
    CP->>DB: Remove the module registration record
```

Because this can delete live servers and their data, uninstalling requires a preview of exactly what will be affected (object count, grant count, roles that will change) and requires the administrator to type the module's name rather than click yes. A role that referenced a removed permission has that key stripped and continues to exist with whatever remains, rather than keeping a dead reference.

## Enforcement placement

**Decisions are made in the application layer, inside use cases**, after the repository load and before the domain method runs. An object-level question ("can this user restart *this* server") needs the object and its ancestry, which only exist once the repository has been called, so no middleware can answer it without doing the repository's job. Putting the check in the use case also means every entry point — HTTP, CLI, a future scheduled task — is covered by construction.

Two conventions make this auditable:

- The acting subject is a field of the use case's command or query input, never ambient state. If an input carries an actor, that use case is responsible for the check.
- The check goes through a domain port, so the domain stays free of transport and storage concerns:

```rust
#[async_trait]
pub trait AccessControl: Send + Sync {
    /// One decision. `scope` is global or a specific object.
    async fn is_allowed(&self, actor: &Actor, permission: Permission, scope: &Scope)
        -> Result<Decision, AccessError>;

    /// Many objects, one round trip — for list endpoints.
    async fn is_allowed_batch(&self, actor: &Actor, permission: Permission, scopes: &[Scope])
        -> Result<Vec<Decision>, AccessError>;

    /// Everything the actor can do at a scope, with the grant behind each entry.
    async fn effective(&self, actor: &Actor, scope: &Scope)
        -> Result<EffectivePermissions, AccessError>;

    /// The objects of a type the actor holds a permission on.
    async fn allowed_objects(&self, actor: &Actor, permission: Permission, of: ObjectType)
        -> Result<AllowedObjects, AccessError>;

    /// Refuse instead of returning false, so a forgotten `else` cannot leak access.
    async fn require(&self, actor: &Actor, permission: Permission, scope: &Scope)
        -> Result<(), AccessError>;
}
```

`Decision` carries the winning scope, subject and grant alongside the verdict — that is what makes the explain views possible without a second evaluation path.

The API layer keeps a coarse extractor for route-level gating, as a fail-closed default so no route is reachable by accident. It is never the authority, and a route gate is never the only check on a mutation.

`allowed_objects` exists so list endpoints filter in SQL. Checking each row after fetching is the mistake every comparable system warns about; an object the actor cannot see must not be in the result set at all.

## Caching and freshness

Sessions carry identity only. Permissions are never embedded in a session or a token, because a revoked grant would keep working until that token expired.

Each request loads the actor's full grant set — their own grants plus their groups' — in one query, into a request-scoped cache. Every check in that request is then answered in memory, which also guarantees a request cannot see two different answers to the same question. Across requests, a process-level cache holds grant sets per subject and permission sets per role, with a global policy version in the key so a single role edit invalidates everything at once instead of requiring a fan-out to be enumerated. Individual decisions — subject × permission × object — are never cached; that key space is unbounded and impossible to invalidate precisely.

Invalidation rides the existing event bus: commands that change roles, grants, or group membership publish an event, and the cache evicts after the transaction commits, never before. Destructive and grant-management operations bypass the cache and read from Postgres.

A change to a role, a grant, or a group membership therefore takes effect on the user's next request, with no re-login.

## Exposing permissions to the dashboard

Hiding a button is not authorization. Everything below is for usability, and the server re-checks on every mutation.

- `GET /api/v1/user/me` returns the caller's identity and their platform-wide permissions, plus the current policy version so the client can tell when its copy is stale. This drives navigation and page-level gating — each page gates on the permission it actually needs, not on a section-wide one, or a user ends up on a page that immediately 403s.
- List and detail responses for objects carry the permissions the caller holds *on that object*, computed for the whole page in one query. The client never asks a second question per row, and there is no per-button check endpoint.
- The permission array describes what the caller can do with rows they can already see; it is never what filters the rows.
- A 403 is a normal outcome, not an impossible state: the client refreshes its copy and tells the user, rather than assuming its cache was right.

## Data model sketch

```sql
-- Every permission that exists. Core's rows are upserted at boot; a module's are
-- registered when it connects.
CREATE TABLE permissions (
  key          text PRIMARY KEY,            -- 'minecraft.server.restart'
  namespace    text NOT NULL,               -- 'minecraft'
  resource     text NOT NULL,               -- 'server'
  action       text NOT NULL,               -- 'restart'
  description  text NOT NULL,
  object_types text[] NOT NULL DEFAULT '{}' -- object types this can be scoped to
);

CREATE TYPE role_scope AS ENUM ('global', 'object', 'both');

CREATE TABLE roles (
  id        uuid PRIMARY KEY,
  slug      text NOT NULL UNIQUE,
  name      text NOT NULL,
  scope     role_scope NOT NULL,
  is_system boolean NOT NULL DEFAULT false  -- protected: Administrator
);

-- Exact keys, or allow-side wildcards: '*', 'minecraft.*', 'minecraft.server.*'
CREATE TABLE role_permissions (
  role_id    uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
  permission text NOT NULL,
  PRIMARY KEY (role_id, permission)
);

CREATE TABLE groups (
  id   uuid PRIMARY KEY,
  slug text NOT NULL UNIQUE,
  name text NOT NULL
);

CREATE TABLE group_members (
  group_id uuid NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
  user_id  uuid NOT NULL REFERENCES users(id)  ON DELETE CASCADE,
  PRIMARY KEY (group_id, user_id)
);

CREATE TYPE subject_kind AS ENUM ('user', 'group');
CREATE TYPE grant_effect AS ENUM ('allow', 'deny');

CREATE TABLE grants (
  id           uuid PRIMARY KEY,
  subject_kind subject_kind NOT NULL,
  subject_id   uuid NOT NULL,
  role_id      uuid REFERENCES roles(id) ON DELETE CASCADE,  -- NULL when inline
  object_type  text,          -- NULL for a global grant
  object_id    uuid,          -- NULL for a global grant
  effect       grant_effect NOT NULL DEFAULT 'allow',
  granted_by   uuid,
  granted_at   timestamptz NOT NULL DEFAULT now(),
  CHECK ((object_type IS NULL) = (object_id IS NULL))
);

-- The inline alternative to role_id: literal keys carried by the grant itself.
-- A grant references a role or carries inline keys, never both, and never
-- neither — enforced in the repository, since the pair spans two tables.
CREATE TABLE grant_permissions (
  grant_id   uuid NOT NULL REFERENCES grants(id) ON DELETE CASCADE,
  permission text NOT NULL,   -- always literal, never a wildcard
  PRIMARY KEY (grant_id, permission)
);
```

A deny grant always carries inline literal keys; it never references a role, since a role may contain wildcards and a deny may not.

Wildcards are expanded on the query side, not the storage side: checking `minecraft.server.restart` looks for the four candidate strings `minecraft.server.restart`, `minecraft.server.*`, `minecraft.*` and `*`, which stays an indexed equality lookup rather than a pattern match.

The hot paths to index are grants by subject (the per-request grant-set load) and grants by object (the reverse "who can act on this" view, and cache invalidation).

## Summary of decisions

| Question | Decision |
|---|---|
| Permission key shape | Fixed `namespace.resource.action`, `[a-z0-9_-]` per segment. |
| Wildcards | Role-level and allow-only: `*`, `ns.*`, `ns.resource.*`. A deny names literal keys. |
| Allow-only, or deny? | Three states: allow, deny, not set. Absent is not deny. |
| Precedence | Nearest scope decides; within a scope, user-addressed before group-addressed, and deny before allow. Default deny. |
| Do administrators bypass deny? | Yes — a global wildcard role short-circuits before any deny is considered. |
| Automatic access on object creation? | No — explicit grants only, aside from the creator and the administrator wildcard. |
| Can groups be grant targets? | Yes. Groups are flat and hold no permissions of their own. |
| Can a grant carry permissions without a role? | Yes — a grant references a role or carries an inline literal list, never both. |
| Global and object-scoped roles: one concept or two? | One table with a `scope` field, validated at assignment. |
| Who may grant? | `core.grant.manage`, plus holding every permission being granted. |
| Can the last administrator be removed? | No — delete, deactivate and role removal are all refused. |
| Seeded roles | Protected `Administrator`; editable `Viewer` and `Operator`. |
| What does deleting a user do? | Suspends for a grace period, then hard-deletes and anonymizes history. Owned objects must be transferred or deleted first. |
| Who registers permissions? | Modules only, under their own namespace. Runtimes declare capabilities, not permissions. |
| Are modules vetted? | No — trusted at install time, same as any installed software. |
| Are agents or modules grant subjects? | No. A module has a service account for attribution in recorded history only. |
| How does an agent authenticate? | Single-use bootstrap token, exchanged on first connect for a rotatable, revocable per-agent credential. |
| How does a module authenticate? | Installed, not enrolled: handshake plus mTLS with per-process certificates. |
| Where is a decision made? | In the use case, through a domain port. The API extractor is a coarse fail-closed gate, never the authority. |
| What is cached? | Grant sets per subject and permission sets per role, keyed with a policy version; per-request memoization on top. Never individual decisions, never in the session. |
| What happens on module uninstall? | Grants for its object types and its catalog entries are deleted, affected roles are stripped. Preview, plus typing the module name to confirm. |
| Multi-tenancy? | Dropped. One installation is one organization; no `organization_id` hedge. |
| Folders | Not built here. The scope walk and the ancestry port are, so the folders feature plugs in without changing evaluation. |
| Audit log | Not built here. Every permission-affecting change emits an event carrying actor, target and time; persistence and the audit interface are a later epic. |
| Bulk operations | Deferred, tracked separately. |

## Open questions

- The full list of distinct actions per object type (server, backup, file, node). This sets the granularity of the catalog and is worked out per feature as those are built, rather than guessed now.
- Whether a non-overridable ceiling (an AWS-style deny an object owner cannot override) is ever needed. If it is, it becomes a separate named layer above this model rather than a change to what deny means here.
