Compare commits

..

5 Commits

Author SHA1 Message Date
chyoo 6d4c4617d3 feat(auth): expose current license app permissions
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-09 13:05:40 +09:00
chyoo 5ebf098316 fix existing user enrollment for instructors
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-13 13:34:24 +09:00
chyoo 8d5e2f8113 patch..
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-13 13:30:58 +09:00
chyoo ad79bf08ad patch..
ci/woodpecker/push/woodpecker Pipeline was canceled
2026-08-13 13:22:03 +09:00
chyoo 2cbd650eca add database url
ci/woodpecker/push/woodpecker Pipeline was canceled
2026-08-13 13:13:52 +09:00
5 changed files with 93 additions and 9 deletions
+10 -3
View File
@@ -28,11 +28,11 @@ steps:
commit="$(printf %.8s "$CI_COMMIT_SHA")" commit="$(printf %.8s "$CI_COMMIT_SHA")"
job="studio-auth-migrate-$commit" job="studio-auth-migrate-$commit"
kubectl --kubeconfig=/tmp/kubeconfig -n studio-system delete job "$job" --ignore-not-found kubectl --kubeconfig=/tmp/kubeconfig -n studio-system delete job "$job" --ignore-not-found
cat <<EOF | kubectl --kubeconfig=/tmp/kubeconfig apply -f - cat <<'EOF' | sed "s/__JOB_NAME__/$job/g" | kubectl --kubeconfig=/tmp/kubeconfig apply -f -
apiVersion: batch/v1 apiVersion: batch/v1
kind: Job kind: Job
metadata: metadata:
name: $job name: __JOB_NAME__
namespace: studio-system namespace: studio-system
spec: spec:
backoffLimit: 1 backoffLimit: 1
@@ -44,10 +44,17 @@ steps:
- name: migrate - name: migrate
image: localhost:5000/studio-auth-be:0.1.1 image: localhost:5000/studio-auth-be:0.1.1
imagePullPolicy: Always imagePullPolicy: Always
command: ["sh", "-lc", "bunx --no-install prisma migrate deploy"] command:
- sh
- -lc
- |
export DATABASE_URL="postgresql://$(printenv POSTGRES_USER):$(bun -e 'process.stdout.write(encodeURIComponent(process.env.POSTGRES_PASSWORD ?? ""))')@studio-auth-db.studio-system.svc.cluster.local:5432/$(printenv POSTGRES_DB)?schema=public"
bunx --no-install prisma migrate deploy
envFrom: envFrom:
- secretRef: - secretRef:
name: studio-auth-be-env name: studio-auth-be-env
- secretRef:
name: studio-auth-db-secret
EOF EOF
if ! kubectl --kubeconfig=/tmp/kubeconfig -n studio-system wait --for=condition=complete "job/$job" --timeout=180s; then if ! kubectl --kubeconfig=/tmp/kubeconfig -n studio-system wait --for=condition=complete "job/$job" --timeout=180s; then
kubectl --kubeconfig=/tmp/kubeconfig -n studio-system logs "job/$job" --all-containers=true || true kubectl --kubeconfig=/tmp/kubeconfig -n studio-system logs "job/$job" --all-containers=true || true
+15
View File
@@ -92,11 +92,26 @@ model License {
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
userLicenses UserLicense[] userLicenses UserLicense[]
appPermissions LicenseAppPermission[]
@@index([isActive]) @@index([isActive])
@@map("licenses") @@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 { model UserLicense {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid userId String @map("user_id") @db.Uuid
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it, mock } from 'bun:test'
const findFirst = mock(async (): Promise<unknown> => 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: [] })
})
})
+22
View File
@@ -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 ?? []
}
}
+18 -6
View File
@@ -8,6 +8,7 @@ import { verifyPassword } from '../security/password'
import { signAccessToken, verifyAccessToken } from '../security/tokens' import { signAccessToken, verifyAccessToken } from '../security/tokens'
import type { AuthUser } from '../types' import type { AuthUser } from '../types'
import { authRequired, type AuthVariables } from './middleware' import { authRequired, type AuthVariables } from './middleware'
import { getLauncherAppPolicy } from './license-policy'
import { import {
clearLoginAttempts, clearLoginAttempts,
assignUserAffiliation, assignUserAffiliation,
@@ -686,6 +687,10 @@ authRoutes.post('/logout', async (c) => {
return c.json({ ok: true }) 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) => { authRoutes.get('/me', authRequired, (c) => {
return c.json({ user: userResponse(c.get('user')) }) return c.json({ user: userResponse(c.get('user')) })
}) })
@@ -826,10 +831,9 @@ authRoutes.post('/instructor/students', authRequired, async (c) => {
const email = body.data.email.toLowerCase() const email = body.data.email.toLowerCase()
const existingUser = await findUserByEmail(email) const existingUser = await findUserByEmail(email)
if (existingUser) return c.json({ error: 'email_already_registered' }, 409)
try { try {
const user = await createManagedUser({ const user = existingUser ?? await createManagedUser({
email, email,
password: body.data.password, password: body.data.password,
name: body.data.name, name: body.data.name,
@@ -839,17 +843,25 @@ authRoutes.post('/instructor/students', authRequired, async (c) => {
licenseCode: body.data.licenseCode, licenseCode: body.data.licenseCode,
}) })
await prisma.userAppRole.create({ await prisma.userAppRole.upsert({
data: { where: {
userId_appKey_roleCode: {
userId: user.id,
appKey: 'LMS',
roleCode: 'STUDENT',
},
},
create: {
userId: user.id, userId: user.id,
appKey: 'LMS', appKey: 'LMS',
roleCode: 'STUDENT', roleCode: 'STUDENT',
assignedById: instructor.id, assignedById: instructor.id,
}, },
update: {},
}) })
const createdUser = await findUserById(user.id) const resolvedUser = await findUserById(user.id)
return c.json({ user: userResponse(createdUser ?? user) }, 201) return c.json({ user: userResponse(resolvedUser ?? user) }, existingUser ? 200 : 201)
} catch { } catch {
return c.json({ error: 'user_create_failed' }, 400) return c.json({ error: 'user_create_failed' }, 400)
} }