Skip to content

Permission and Visibility System

Overview

The Permission and Visibility System is the security backbone of the Progressive World-Building Platform. It governs who can do what within a project and, critically, what content each user is allowed to see. Because the platform deals with collaborative storytelling where hidden information is central to the experience -- secret factions, private character backstories, unpublished plot developments -- the permission system must go beyond simple CRUD access control. It must shape every API response so that each user sees a world consistent with their role, with no hints or metadata leaking the existence of content they should not know about.

The system is built around an IPermissionService interface in the Application layer, backed by a fixed role-based implementation for Phase 1. This design deliberately separates the "what permissions exist" question from the "how are they evaluated" question, allowing the platform to evolve from fixed roles to configurable per-project permissions without rewriting any feature code. Every query handler, command handler, and graph endpoint delegates to this service for both action authorization and content filtering.

Project-level visibility adds another dimension: projects themselves can be public, unlisted, or private, controlling discoverability and access before role-based checks even begin.

Goals

  • Enforce a clear role hierarchy (Owner > Storyteller > Co-Creator > Player > Viewer) across all features
  • Guarantee that private content is completely absent from API responses for unauthorized users
  • Provide a single, consistent permission interface that all feature modules consume
  • Support project-level visibility modes (public, unlisted, private) for discoverability control
  • Design for future extensibility: the permission implementation can be swapped without changing feature code
  • Minimize performance overhead through efficient role lookups and optional caching
  • Enable comprehensive integration testing of permission boundaries

User Stories

As a Game Master (Owner/Storyteller), I want to create secret factions and hidden character relationships that my players cannot see, so I can preserve narrative surprises during our campaign.

As a Writer (Co-Creator), I want to draft timeline entries in "concept" status that are invisible to readers, so I can develop ideas without exposing unfinished work.

As a Player, I want to see only published timeline content and public characters, with my private character visible only to me and the storyteller, so the world feels consistent and my secrets are protected.

As a Viewer, I want to browse published content in public projects without needing an account, so I can follow a campaign or story as a reader.

As a Project Owner, I want to set my project to "unlisted" so only people with the direct link can find it, without making it fully private.

As a Co-Creator, I want to see all content (including in-development material) but not be able to manage members or project settings, so I can contribute fully without administrative overhead.

Functional Description

Role Hierarchy

The platform defines five roles in a strict hierarchy. Each higher role inherits all permissions of the roles below it, plus additional capabilities.

Complete Permission Matrix

ActionOwnerStorytellerCo-CreatorPlayerViewer
Manage project settingsYes----
Manage members / invitationsYes----
Delete projectYes----
Create/edit timeline updatesYesYesYes--
Create/edit sections and itemsYesYesYes--
Change timeline status to PublishedYesYes---
Create NPC charactersYesYesYes--
Create own PC characterYesYesYesYes-
Edit any characterYesYesYes--
Edit own PC characterYesYesYesYes-
Create relationships (any)YesYesYes--
Create relationships (own character only)YesYesYesYes-
Create factionsYesYesYes--
Manage faction membershipsYesYesYes--
Create faction relationshipsYesYesYes--
Create secret faction relationshipsYesYes---
Moderate content (delete others' comments)YesYes---
Post commentsYesYesYesYes-
View all content (all statuses)YesYesYes--
View published content onlyYesYesYesYesYes
View private charactersCreator + Owner + Storyteller
View private relationshipsCreator + Owner + Storyteller
View secret faction relationshipsYesYes---

The IPermissionService Interface

The IPermissionService lives in the Application layer (not Infrastructure, not Domain). This is deliberate: permission evaluation is an application concern that coordinates between domain knowledge (role hierarchy, content ownership) and infrastructure (database lookups). Feature modules reference the interface through dependency injection and never depend on a concrete implementation.

The interface exposes two categories of methods:

Action checks answer "Can this user perform this action?" They return a boolean and are called before executing commands. If the check fails, the handler returns 403 Forbidden.

  • CanAccessProject -- can the user see this project at all?
  • CanEditTimeline -- can the user create or modify timeline updates?
  • CanCreateCharacter -- can the user create a character (of a given type)?
  • CanEditCharacter -- can the user edit this specific character?
  • CanManageMembers -- can the user invite, remove, or change roles of members?
  • CanModerateContent -- can the user delete others' comments or resolve threads?
  • CanCreateRelationship -- can the user create a relationship (possibly restricted to own characters)?
  • CanCreateFaction -- can the user create or edit factions?
  • CanPublishTimeline -- can the user change a timeline status to Published?

Visibility filters answer "What subset of this data can the user see?" They accept a collection and return a filtered collection. These are called in query handlers before assembling the API response.

  • FilterByVisibility -- generic filter that inspects content type and applies the correct rules
  • Specialized filter logic per content type (timelines by status, characters by visibility, relationships by visibility and ownership, faction relationships by secrecy flag)

Permission Check Flow

Every API request follows this lifecycle:

Permission checks are performed per-handler, not in middleware. Middleware handles authentication (verifying the JWT and extracting the user identity). Authorization is the handler's responsibility because each operation has unique permission requirements that depend on the specific action, the target entity, and sometimes the entity's state.

This approach avoids the pitfalls of a generic authorization middleware that would need complex configuration to express rules like "Players can create relationships, but only for their own characters."

Content Visibility Filtering

The visibility filtering pipeline ensures that each API response contains only the content the requesting user is authorized to see. The pipeline applies different rules depending on the content type.

Content Visibility Decision by Type and Role

Cascading Visibility

Visibility filtering has cascading effects. When a character is hidden from a user, all relationships involving that character must also be hidden, even if the relationships themselves are public. Otherwise, a public relationship would reference a character the user cannot see, revealing the existence of hidden content.

The filtering pipeline processes in this order:

  1. Filter characters by visibility
  2. Filter relationships by visibility
  3. Remove any relationships where either endpoint character was filtered out in step 1
  4. Filter faction relationships by secrecy flag

This ordering prevents orphaned references in the response.

Project-Level Visibility

Before role-based permissions apply, the project's own visibility setting determines who can even attempt to access it.

Public projects: Listed in search results and public project directories. Non-members can browse published content with Viewer-level access. Members see content according to their assigned role.

Unlisted projects: Not listed in search or directories. Accessible via direct link (/api/projects/VNO). Non-members with the link get Viewer-level access. Members see content according to their role.

Private projects: Not listed anywhere. Only project members can access. Non-members receive 403 Forbidden regardless of whether they have the URL. The project's existence is not confirmed or denied -- a non-member receives the same 404 response whether the project exists or not.

Swappable Permission Implementation

The permission system is designed with a strategy pattern: the IPermissionService interface defines the contract, and the concrete implementation can be replaced via dependency injection without any changes to feature code.

Phase 1 (RoleBasedPermissionService): Evaluates the fixed permission matrix defined in this document. The role hierarchy is hardcoded. Simple, fast, fully testable.

Future (ConfigurablePermissionService): Reads per-project permission overrides from a configuration table. A project owner could, for example, grant Players the ability to create NPC characters in their specific project. Falls back to the default role-based matrix for any permissions not explicitly overridden.

The swap happens in a single line of DI registration in the API host project. Feature modules are completely unaware of which implementation they are using.

Permission Caching

Role lookups (determining a user's role in a project) are the most frequent permission operation. Every handler in every request needs this information. Without caching, this means a database query per request at minimum.

Phase 1 strategy: per-request caching. The IPermissionService implementation caches the user's role for the duration of a single HTTP request (scoped lifetime). This means that if a request handler calls CanEditTimeline and then FilterByVisibility, the role lookup query runs only once.

Future consideration: short-lived distributed cache. If performance profiling reveals that role lookups are a bottleneck, a short-TTL cache (e.g., 30 seconds in Redis) can be introduced. The TTL must be short because role changes (promoting a user from Player to Co-Creator) should take effect quickly. Cache invalidation on role change is the alternative but adds complexity.

Per-request caching is sufficient for Phase 1 because:

  • Each request makes at most a few permission calls, all needing the same role
  • The ProjectMember table is small (projects have at most tens of members) and indexed
  • Avoiding a distributed cache reduces infrastructure complexity

Cross-Feature Permission Application

Every feature module in the platform uses IPermissionService in the same pattern:

  1. Command handlers call an action check before mutating state. If denied, return 403.
  2. Query handlers call visibility filters before returning data. The filter is applied to the query results, not the database query itself, so the filtering logic stays in the application layer (consistent with the ADR-006 decision against database-level RLS).
  3. Graph endpoints apply visibility filtering to the assembled graph before returning it. Characters the user cannot see become absent nodes, and their relationships become absent edges.
Feature ModuleAction Checks UsedVisibility Filters Used
ProjectsCanAccessProject, CanManageMembersProject visibility mode check
TimelinesCanEditTimeline, CanPublishTimelineFilter by DevelopmentStatus
CharactersCanCreateCharacter, CanEditCharacterFilter by Character.Visibility
FactionsCanCreateFactionFilter FactionRelationships by IsSecret
CollaborationCanModerateContentFilter comments on hidden content
GraphsCanAccessProjectFull cascade: characters, relationships, factions

Data Flow

Write Operation: Creating a Private Relationship

Read Operation: Querying the Character Graph

Key Components

IPermissionService (Application Layer)

The central interface that all feature modules depend on. Defines action check methods and visibility filtering methods. Lives in the shared Application layer (or Common project) so all feature modules can reference it.

RoleBasedPermissionService (Infrastructure Layer)

The Phase 1 concrete implementation. Contains the hardcoded permission matrix mapping roles to allowed actions. Resolves the user's role in the project by querying ProjectMember, then evaluates the matrix. Registered as a scoped service (per-request lifetime) to enable per-request role caching.

ProjectMember Entity

The source of truth for "what role does this user have in this project?" The permission service queries this entity to resolve roles. The entity also tracks invitation status (pending, accepted, declined), and only accepted members have active roles.

Permission Check Result

When an action check fails, the handler returns a standardized error response. For authorization failures within a known project (user is a member but lacks the required role), the response is 403 Forbidden with a message indicating insufficient permissions. For project access failures where the user is not a member of a private project, the response is 404 Not Found (to avoid confirming the project's existence).

Visibility Filter Pipeline

A composable set of filtering rules applied in sequence. Each content type has its own filtering logic, but they share the same interface. The pipeline is ordered to handle cascading dependencies (characters filtered before relationships).

Feature Interactions

  • Authentication (01): Provides the authenticated user identity (userId from JWT) that the permission service uses for all checks. Without authentication, no permission evaluation can occur.
  • Project Management (02): Provides the ProjectMember data that determines each user's role. Project creation automatically assigns the Owner role. Invitation acceptance creates new ProjectMember records.
  • Content Timeline System (03): Uses CanEditTimeline and CanPublishTimeline action checks. Timeline queries are filtered by DevelopmentStatus for Players and Viewers.
  • Character System (04): Uses CanCreateCharacter and CanEditCharacter. Character queries filter by Visibility. Relationship queries additionally cascade character filtering.
  • Faction System (05): Uses CanCreateFaction. Faction relationship queries filter by IsSecret flag. Faction memberships reference characters, so character visibility cascading applies.
  • Real-time and Collaboration (07): SignalR event broadcasting must respect permissions -- private content events are not sent to unauthorized users. Comment visibility depends on the visibility of the target content.
  • Search and Navigation (09): Search results must be filtered through the same visibility pipeline. A Player searching for a term that matches a private character's name must receive no results for that character.

Edge Cases and Error Handling

User with no role (non-member accessing a public project): Treated as having Viewer-level access. They see only published content. They cannot perform any write operations.

Pending invitation: A user with a pending invitation has no active role. They are treated as a non-member until they accept. The invitation itself is a separate concern from permission evaluation.

Owner transfer: If project ownership is transferred, the old Owner's role changes and the new Owner gains full control. The permission service evaluates current role at query time, so this takes effect immediately.

Role change mid-session: If a Storyteller demotes a Co-Creator to Player while that user has an active session, their next API request will see the new role. Per-request role resolution (no long-lived cache) ensures role changes are reflected promptly.

Deleted user references: If a character's CreatedBy user is removed from the project, the character persists but the former creator loses special visibility. The character's Visibility setting determines who can see it going forward.

Concurrent role checks: Multiple requests from the same user hitting different handlers will each independently resolve the user's role. Per-request scoping avoids cache coherence issues across concurrent requests.

Graph endpoints with heavy filtering: For projects with many private characters, the graph may look significantly different per role. The API does not indicate that content was filtered -- the graph simply has fewer nodes and edges. This is by design (full omission strategy).

Empty results after filtering: If all content is filtered out, the API returns an empty collection, not a special "no content available" message. This avoids revealing that hidden content exists.

Phase and Priority

Phase 1 (Core): The permission system is foundational and ships with the first release. The fixed role-based implementation covers all Phase 1 and Phase 2 features. Every feature module depends on it.

Future Phase: The ConfigurablePermissionService is a future enhancement that allows project owners to customize permissions per project. This requires a permission configuration UI and storage, and is explicitly deferred to avoid unnecessary complexity in early development.