Permissions in the UI
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
- Read the user's roles from
GET /api/v1/users/currentwhen the app starts and after every sign-in. The roles are inlocalUser.roles, an array of objects whoserolefield is the role's name, such asmusic_user. A user can have several roles. - 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.
- Match a capability against the assignments. A capability is
Aspect.Action, such asMusicTracks.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. - Gate the whole app on its login capability.
MusicApp.Loginfor a music app,PhotosApp.Loginfor photos,CinemaApp.Loginfor video,AdminApp.Loginfor 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. - 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.
- Hide or disable controls the user cannot use, and skip requests for data they cannot read, instead of sending them and showing the
403. - Still handle
403on 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 a403, tell the user the action is not allowed, keep the app running, and fetchGET /api/v1/users/currentagain so the controls catch up. A403never signs the user out. - 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 | *.* |
administrator | Every capability except Invitations.Create, Invitations.Update, and Invitations.Delete |
media_apps_user | Base, cross-app, Music, Photos, and Cinema |
music_user | Base, cross-app, and Music |
photos_user | Base, cross-app, and Photos |
cinema_user | Base, cross-app, and Cinema |
newcomer | Base, plus MusicApp.Login, PhotosApp.Login, and CinemaApp.Login |
| Group | Assignments |
|---|---|
| Base | CurrentUser.Read, CurrentUser.Update, Notifications.Read, Notifications.Update |
| Cross-app | Libraries.Read, Indexing.Read, Jobs.Read |
| Music | MusicApp.*, MusicArtists.*, MusicReleases.*, MusicGenres.*, MusicTracks.*, MusicHistory.*, Ratings.* |
| Photos | PhotosApp.*, Photos.*, PhotoFaces.*, PhotoAlbums.* |
| Cinema | CinemaApp.*, 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:
| Feature | Capability |
|---|---|
| Browse tracks, releases, artists | MusicTracks.Read, MusicReleases.Read, MusicArtists.Read |
| Play a track | MusicTracks.Play |
| Report plays, read history | MusicHistory.Create, MusicHistory.Read |
| Rate, unrate, read ratings | Ratings.Create, Ratings.Delete, Ratings.Read |
| View photos, edit a photo's albums | Photos.Read, Photos.Update |
| Photo albums | PhotoAlbums.Read, PhotoAlbums.Create, PhotoAlbums.Update, PhotoAlbums.Delete |
| Indexing status, jobs, notifications | Indexing.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
403and the codepat_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
Nonefor 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
- 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.
- Remove Music User from that user, leaving Newcomer. Without restarting the app, use a control and confirm the
403shows a message, the app keeps running, and the controls update after the current user is read again. - Restart the app as that user and confirm it shows the ask-for-a-role state instead of empty lists or errors.
- Remove every role from the user and confirm the app shows the no-access screen.
- Check the network log: the app sends no requests for data the user cannot read.
Was this article helpful?