diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 299c758..7b335e2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -92,11 +92,26 @@ model License { createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) userLicenses UserLicense[] + appPermissions LicenseAppPermission[] @@index([isActive]) @@map("licenses") } +model LicenseAppPermission { + id String @id @default(uuid()) @db.Uuid + licenseId String @map("license_id") @db.Uuid + appKey String @map("app_key") + canLaunch Boolean @default(true) @map("can_launch") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + license License @relation(fields: [licenseId], references: [id], onDelete: Cascade) + + @@unique([licenseId, appKey]) + @@index([appKey]) + @@map("license_app_permissions") +} + model UserLicense { id String @id @default(uuid()) @db.Uuid userId String @map("user_id") @db.Uuid diff --git a/src/auth/license-policy.test.ts b/src/auth/license-policy.test.ts new file mode 100644 index 0000000..42c7141 --- /dev/null +++ b/src/auth/license-policy.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, mock } from 'bun:test' + +const findFirst = mock(async (): Promise => null) +mock.module('../prisma', () => ({ prisma: { userLicense: { findFirst } } })) +const { getLauncherAppPolicy } = await import('./license-policy') + +describe('launcher license policy', () => { + it('returns the current license app policy', async () => { + findFirst.mockResolvedValueOnce({ license: { appPermissions: [{ appKey: 'LMS', canLaunch: true }, { appKey: 'OTHER', canLaunch: false }] } }) + expect(await getLauncherAppPolicy('student')).toEqual({ + defaultCanLaunch: true, + permissions: [{ appKey: 'LMS', canLaunch: true }, { appKey: 'OTHER', canLaunch: false }], + }) + expect(findFirst).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + userId: 'student', startsAt: { lte: expect.any(Date) }, + OR: [{ expiresAt: null }, { expiresAt: { gt: expect.any(Date) } }], + license: { isActive: true }, + }), + orderBy: { startsAt: 'desc' }, + })) + }) + + it('denies by default without an active license', async () => { + findFirst.mockResolvedValueOnce(null) + expect(await getLauncherAppPolicy('student')).toEqual({ defaultCanLaunch: false, permissions: [] }) + }) +}) diff --git a/src/auth/license-policy.ts b/src/auth/license-policy.ts new file mode 100644 index 0000000..04129ad --- /dev/null +++ b/src/auth/license-policy.ts @@ -0,0 +1,22 @@ +import { prisma } from '../prisma' + +export async function getLauncherAppPolicy(userId: string) { + const now = new Date() + const current = await prisma.userLicense.findFirst({ + where: { + userId, + startsAt: { lte: now }, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + license: { isActive: true } + }, + orderBy: { startsAt: 'desc' }, + select: { + license: { select: { appPermissions: { select: { appKey: true, canLaunch: true } } } } + } + }) + return { + // Match the admin license policy: unspecified apps are allowed for active licenses. + defaultCanLaunch: Boolean(current), + permissions: current?.license.appPermissions ?? [] + } +} diff --git a/src/auth/routes.ts b/src/auth/routes.ts index 27e6c26..f795755 100644 --- a/src/auth/routes.ts +++ b/src/auth/routes.ts @@ -8,6 +8,7 @@ import { verifyPassword } from '../security/password' import { signAccessToken, verifyAccessToken } from '../security/tokens' import type { AuthUser } from '../types' import { authRequired, type AuthVariables } from './middleware' +import { getLauncherAppPolicy } from './license-policy' import { clearLoginAttempts, assignUserAffiliation, @@ -686,6 +687,10 @@ authRoutes.post('/logout', async (c) => { return c.json({ ok: true }) }) +authRoutes.get('/launcher-app-policy', authRequired, async (c) => { + return c.json(await getLauncherAppPolicy(c.get('user').id)) +}) + authRoutes.get('/me', authRequired, (c) => { return c.json({ user: userResponse(c.get('user')) }) })