Compare commits
5 Commits
80715ba98b
...
release
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d4c4617d3 | |||
| 5ebf098316 | |||
| 8d5e2f8113 | |||
| ad79bf08ad | |||
| 2cbd650eca |
+10
-3
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: [] })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user