Skip to main content

Permissions in the UI

Claude
Authored by Claude · Last updated AI generated documentation written by reading the source code
self-hosted

Every request to the Media Server is checked against the roles of the user behind the credential. Cardinal's own apps read the same roles up front, so a user never sees a button that the server will refuse, or a page that cannot load. The check in the app only decides what to show. The server decides what is allowed, on every request, and an app must still handle a refusal.

Rules​

  1. Read the user's roles from GET /api/v1/users/current when the app starts and after every sign-in. The roles are in localUser.roles, an array of objects whose role field is the role's name, such as music_user. A user can have several roles.
  2. Expand roles to capability assignments with the role table below, and combine the assignments of all the user's roles. A role name the app does not know grants nothing in the app.
  3. Match a capability against the assignments. A capability is Aspect.Action, such as MusicTracks.Play. An assignment grants it when its aspect equals the capability's aspect or is *, and its action equals the capability's action or is *. *.* grants everything.
  4. Gate the whole app on its login capability. MusicApp.Login for a music app, PhotosApp.Login for photos, CinemaApp.Login for video, AdminApp.Login for server administration. A user without it does not get past sign-in: show that they have no access to this app, not an app full of errors.
  5. Check the capabilities each feature needs, not only the login capability. A Newcomer can log into every app but read nothing in them. Each endpoint's description in the API reference lists the capabilities it requires. When a user can enter the app but lacks the capabilities to read its media, tell them to ask the server's owner for a role.
  6. Hide or disable controls the user cannot use, and skip requests for data they cannot read, instead of sending them and showing the 403.
  7. Still handle 403 on every request. The owner can change a user's roles at any moment, and the server applies the change on the very next request, while the app is still showing the old ones. On a 403, tell the user the action is not allowed, keep the app running, and fetch GET /api/v1/users/current again so the controls catch up. A 403 never signs the user out.
  8. Never treat the app's check as security. Anyone can call the API directly; the server's check is the one that counts. The app's check exists so the interface matches what the server will allow.

Roles and their capabilities​

Role (role)Capability assignments
owner*.*
administratorEvery capability except Invitations.Create, Invitations.Update, and Invitations.Delete
media_apps_userBase, cross-app, Music, Photos, and Cinema
music_userBase, cross-app, and Music
photos_userBase, cross-app, and Photos
cinema_userBase, cross-app, and Cinema
newcomerBase, plus MusicApp.Login, PhotosApp.Login, and CinemaApp.Login
GroupAssignments
BaseCurrentUser.Read, CurrentUser.Update, Notifications.Read, Notifications.Update
Cross-appLibraries.Read, Indexing.Read, Jobs.Read
MusicMusicApp.*, MusicArtists.*, MusicReleases.*, MusicGenres.*, MusicTracks.*, MusicHistory.*, Ratings.*
PhotosPhotosApp.*, Photos.*, PhotoFaces.*, PhotoAlbums.*
CinemaCinemaApp.*, TVChannels.*, CinemaHistory.*, CinemaCollections.*, CinemaPlaylists.*, Movies.*, TVEpisodes.*

New users get Newcomer automatically, and the Guest Account is an Administrator. The roles are defined in the open-source @cardinalapps/access-control library, which a TypeScript app can use in place of this table.

Capabilities that common app features need:

FeatureCapability
Browse tracks, releases, artistsMusicTracks.Read, MusicReleases.Read, MusicArtists.Read
Play a trackMusicTracks.Play
Report plays, read historyMusicHistory.Create, MusicHistory.Read
Rate, unrate, read ratingsRatings.Create, Ratings.Delete, Ratings.Read
View photos, edit a photo's albumsPhotos.Read, Photos.Update
Photo albumsPhotoAlbums.Read, PhotoAlbums.Create, PhotoAlbums.Update, PhotoAlbums.Delete
Indexing status, jobs, notificationsIndexing.Read, Jobs.Read, Notifications.Read

Reference implementation​

api() comes from Connecting and staying connected.

declare function api(path: string, init?: RequestInit): Promise<Response>

type Role = { grants: string[], except?: string[] }

const BASE = ['CurrentUser.Read', 'CurrentUser.Update', 'Notifications.Read', 'Notifications.Update']
const CROSS_APP = ['Libraries.Read', 'Indexing.Read', 'Jobs.Read']
const MUSIC = ['MusicApp.*', 'MusicArtists.*', 'MusicReleases.*', 'MusicGenres.*', 'MusicTracks.*', 'MusicHistory.*', 'Ratings.*']
const PHOTOS = ['PhotosApp.*', 'Photos.*', 'PhotoFaces.*', 'PhotoAlbums.*']
const CINEMA = ['CinemaApp.*', 'TVChannels.*', 'CinemaHistory.*', 'CinemaCollections.*', 'CinemaPlaylists.*', 'Movies.*', 'TVEpisodes.*']

const ROLES: Record<string, Role> = {
owner: { grants: ['*.*'] },
administrator: { grants: ['*.*'], except: ['Invitations.Create', 'Invitations.Update', 'Invitations.Delete'] },
media_apps_user: { grants: [...BASE, ...CROSS_APP, ...MUSIC, ...PHOTOS, ...CINEMA] },
music_user: { grants: [...BASE, ...CROSS_APP, ...MUSIC] },
photos_user: { grants: [...BASE, ...CROSS_APP, ...PHOTOS] },
cinema_user: { grants: [...BASE, ...CROSS_APP, ...CINEMA] },
newcomer: { grants: [...BASE, 'MusicApp.Login', 'PhotosApp.Login', 'CinemaApp.Login'] },
}

let roleNames: string[] = []

function matches(assignment: string, capability: string): boolean {
const [aspect, action] = capability.split('.')
const [grantedAspect, grantedAction] = assignment.split('.')
return (grantedAspect === '*' || grantedAspect === aspect) && (grantedAction === '*' || grantedAction === action)
}

export function can(capability: string): boolean {
return roleNames.some((name) => {
const role = ROLES[name]
if (!role || role.except?.includes(capability)) return false
return role.grants.some((assignment) => matches(assignment, capability))
})
}

export async function loadCurrentUser(): Promise<void> {
const res = await api('/users/current')
if (!res.ok) throw new Error(`Could not read the current user (${res.status})`)
const { localUser } = await res.json()
roleNames = (localUser?.roles ?? []).map((assignment: { role: string }) => assignment.role)
// re-render anything gated with can()
}

// Wrap requests a control triggers, so a refusal updates the controls instead of breaking the app.
export async function attempt(path: string, init?: RequestInit): Promise<Response | null> {
const res = await api(path, init)
if (res.status !== 403) return res
void loadCurrentUser()
// tell the user the action is not allowed
return null
}

Gating with it:

if (!can('MusicApp.Login')) showNoAccess()
else if (!can('MusicTracks.Read')) showAskForRole()

playButton.hidden = !can('MusicTracks.Play')
rateButton.hidden = !can('Ratings.Create')
if (can('Jobs.Read')) refreshJobs()

Edge cases​

  • The server is newer than the app. A new role, or a new capability added to an existing role, is unknown to the app's table. The app then hides something the user can do, which is safer than the reverse. Keep the table, or the access-control library, up to date.
  • The administrator's exceptions. Only the owner manages invitations. An administrator can read them but not create, change, or revoke them.
  • Personal access tokens. A token carries its user's roles exactly, so the same checks apply. Endpoints that manage personal access tokens refuse every token with 403 and the code pat_not_permitted, whatever the roles.
  • Endpoints with no capability. Some endpoints, such as the event stream, need a credential but no capability. The API reference says None for these.
  • Roles change while the app is open. Nothing tells the app. It finds out from a 403, or the next time it reads the current user.

How to verify it works​

  1. Create a test user with only Music User, and a personal access token for it. Confirm the app opens and every music control is shown.
  2. Remove Music User from that user, leaving Newcomer. Without restarting the app, use a control and confirm the 403 shows a message, the app keeps running, and the controls update after the current user is read again.
  3. Restart the app as that user and confirm it shows the ask-for-a-role state instead of empty lists or errors.
  4. Remove every role from the user and confirm the app shows the no-access screen.
  5. Check the network log: the app sends no requests for data the user cannot read.

Was this article helpful?