Skip to Content
📘 ClubUp v0.1 — koncepčný návrh. Implementácia ešte nezačala.
Autentifikácia (SSO)RBAC — Role-Based Access Control

RBAC — Role-Based Access Control

Mapovanie SportUp rolí na ClubUp roly + permission matrix.

ClubUp roly

RolaPopisTypický užívateľ
studentDefault — môže si kúpiť kurzy, prechádzať obsah, robiť testyVýkonný riaditeľ klubu, manažér
instructorLektor — pripravuje obsah, vedie webinárePedagóg z FRI ŽU, externý expert
content_managerEditor obsahu — vytvára/edituje drafts kurzovInterný pracovník LTK Solutions
adminPlná správa — publish, refunds, vydávanie certifikátovOwner / CTO / DevOps

Roly sú kumulatívne — admin môže robiť všetko, čo content_manager + instructor + student.

Mapovanie zo SportUp claims

V ID tokene je sportup_roles[] so SportUp + ClubUp-špecifickými rolami. Mapovanie:

sportup_roles valueClubUp rolaPoznámka
sportup:userstudentDefault; každý prihlásený SportUp user dostane student rolu v ClubUp
clubup:studentstudentExplicitne pre case keď sportup:user chýba
clubup:instructorinstructorPridelené SportUp adminom alebo ClubUp admin requestom
clubup:content_managercontent_managerPridelené SportUp adminom
clubup:adminadminPridelené SportUp adminom (highest privilege)
// packages/auth/rbac.ts export type Role = 'student' | 'instructor' | 'content_manager' | 'admin'; export function mapRoles(sportupRoles: string[]): Role[] { const roles = new Set<Role>(); if (sportupRoles.includes('sportup:user') || sportupRoles.includes('clubup:student')) { roles.add('student'); } if (sportupRoles.includes('clubup:instructor')) roles.add('instructor'); if (sportupRoles.includes('clubup:content_manager')) roles.add('content_manager'); if (sportupRoles.includes('clubup:admin')) roles.add('admin'); return Array.from(roles); }

Permission matrix

Permissions sú definované cez stable string keys. Každý key má zoznam rolí, ktoré ho majú:

// packages/auth/permissions.ts export const PERMISSIONS = { // === Course content === 'course.read.published': ['student', 'instructor', 'content_manager', 'admin'], 'course.read.draft': ['content_manager', 'admin'], 'course.create': ['content_manager', 'admin'], 'course.update': ['content_manager', 'admin'], 'course.publish': ['admin'], 'course.archive': ['admin'], 'course.delete': ['admin'], 'course.create_new_version': ['content_manager', 'admin'], // === Levels / Topics / Modules / Parts === 'level.create': ['content_manager', 'admin'], 'level.update': ['content_manager', 'admin'], 'level.delete': ['admin'], 'topic.create': ['content_manager', 'admin'], 'topic.update': ['content_manager', 'admin'], 'topic.delete': ['admin'], 'module.create': ['content_manager', 'admin'], 'module.update': ['content_manager', 'admin'], 'module.delete': ['admin'], 'part.create': ['content_manager', 'admin'], 'part.update': ['content_manager', 'admin'], 'part.delete': ['admin'], // === Tests & Questions === 'test.create': ['content_manager', 'admin'], 'test.update': ['content_manager', 'admin'], 'test.delete': ['admin'], 'question.create': ['content_manager', 'admin'], 'question.update': ['content_manager', 'admin'], 'question.delete': ['admin'], 'test_attempt.start': ['student', 'instructor', 'content_manager', 'admin'], 'test_attempt.submit': ['student', 'instructor', 'content_manager', 'admin'], 'test_attempt.reset': ['admin'], // reset count for student // === Webinars === 'webinar.create': ['instructor', 'content_manager', 'admin'], 'webinar.update': ['instructor', 'content_manager', 'admin'], 'webinar.delete': ['admin'], 'webinar.upload_recording': ['instructor', 'content_manager', 'admin'], // === Enrollments === 'enrollment.read.own': ['student', 'instructor', 'content_manager', 'admin'], 'enrollment.read.all': ['admin'], 'enrollment.create': ['admin'], // manuálne udelenie 'enrollment.cancel': ['admin'], 'enrollment.extend': ['admin'], // === Orders & Payments === 'order.read.own': ['student', 'admin'], 'order.read.all': ['admin'], 'order.create': ['student', 'instructor', 'content_manager', 'admin'], // všetci si kupujú 'order.refund': ['admin'], // === Certificates === 'certificate.read.own': ['student', 'instructor', 'content_manager', 'admin'], 'certificate.read.all': ['admin'], 'certificate.issue': ['admin'], // manuálne (Fáza 1) 'certificate.revoke': ['admin'], // === Audit & system === 'audit.read': ['admin'], 'system.settings': ['admin'], } as const; export type Permission = keyof typeof PERMISSIONS; export function can(roles: Role[], permission: Permission): boolean { const allowed = PERMISSIONS[permission]; return roles.some((r) => allowed.includes(r)); }

Pomocné funkcie

// packages/auth/guards.ts import { auth } from './index'; import { can, type Permission } from './permissions'; export async function requirePermission(permission: Permission) { const session = await auth(); if (!session) throw new UnauthorizedError(); if (!can(session.user.roles, permission)) { throw new ForbiddenError(permission); } return session; } export async function requireRole(role: Role) { const session = await auth(); if (!session) throw new UnauthorizedError(); if (!session.user.roles.includes(role)) { throw new ForbiddenError(`role:${role}`); } return session; } export class UnauthorizedError extends Error { code = 'unauthorized'; } export class ForbiddenError extends Error { code = 'forbidden'; }

Použitie

Server Action

'use server'; import { requirePermission } from '@clubup/auth'; export async function publishCourse(courseId: string) { await requirePermission('course.publish'); // ... }

Route Handler

import { requirePermission } from '@clubup/auth'; export async function POST(req: Request, { params }: { params: { id: string } }) { try { await requirePermission('course.publish'); // ... } catch (e) { if (e instanceof UnauthorizedError) return new Response('unauthorized', { status: 401 }); if (e instanceof ForbiddenError) return new Response('forbidden', { status: 403 }); throw e; } }

React Server Component

import { auth } from '@/auth'; import { can } from '@clubup/auth/permissions'; export default async function CoursePage() { const session = await auth(); const showEditButton = session && can(session.user.roles, 'course.update'); return ( <div> {showEditButton && <EditCourseButton />} </div> ); }

Ownership checks

Niektoré permissions sú „own only” (napr. enrollment.read.own). Tu nestačí role check — treba aj ownership check:

export async function requireEnrollmentAccess(enrollmentId: string) { const session = await requirePermission('enrollment.read.own'); const enrollment = await findEnrollment(enrollmentId); if (!enrollment) throw new NotFoundError(); // Admin môže čítať všetky if (session.user.roles.includes('admin')) return enrollment; // Student môže len vlastné if (enrollment.studentId !== session.user.sportupPersonId) { throw new ForbiddenError('not_owner'); } return enrollment; }

Org-level permissions (Fáza 2)

Keď klub kupuje kurzy pre svojich členov, potrebujeme org-level admina — užívateľa, ktorý môže vidieť progress všetkých študentov v klube. Toto NIE JE v MVP, ale architektúra je pripravená:

// V claims tokenu: sportup_orgs: [ { org_id: 'sportup_org_id_klub', roles: ['coach', 'manager', 'clubup:org_admin'] } ]

Vyhodnotenie: ak má clubup:org_admin rolu v konkrétnej organizácii, vidí všetkých študentov, ktorí majú aktívny enrollment so sponsorOrgId rovnajúcim sa danému org_id.

Audit pre privileged akcie

Každá akcia s permission *.delete, *.publish, *.refund, *.issue, *.revoke, *.reset automaticky vytvorí záznam v audit_logs:

import { audit } from '@clubup/db/audit'; export async function publishCourse(courseId: string) { const session = await requirePermission('course.publish'); const result = await doPublish(courseId); await audit({ actor: session.user.sportupPersonId, actorRoles: session.user.roles, action: 'course.publish', target: { type: 'course', id: courseId }, timestamp: new Date(), }); return result; }

Audit log je read-only pre admin rolu, immutable, retention 24 mesiacov (alebo dlhšie podľa operations/gdpr.md).