feat: repo permission system + Gitea-only provisioning + env templates

Schema:
- AllowedRepo model: admin-controlled list of deployable repos
  - giteaOwner/giteaRepo: always from Gitea (git.dfmpanel.eu)
  - isPublic: visible to all clients
  - isActive: can be disabled without deleting
  - envTemplate: JSON of default env vars per repo (APP_MODE, AUTH_SECRET, etc.)
- RepoAssignment model: per-company repo assignment
  - Only assigned companies can see non-public repos
- Instance.allowedRepoId: links instance to its source repo

Provisioning changes:
- Always uses Gitea (gitProvider: 'gitea'), never GitHub
- Branch is always 'main'
- Repo URL: {GITEA_URL}/{owner}/{repo}.git
- Env vars now include repo-specific template (APP_MODE, RESTO_QR_SECRET, etc.)
  merged with AOS license vars and caller overrides
- Permission check: repo must be public OR assigned to the company

API:
- GET /api/aos/repos — list all (admin) or client-scoped (?companyId=X)
- POST /api/aos/repos — add a repo to allowed list
- PATCH /api/aos/repos/[id] — update + assign/unassign to company
- DELETE /api/aos/repos/[id] — remove (blocked if instances exist)

Seed:
- restov1 added as public allowed repo with env template:
  APP_MODE=demo, RESTO_RESTAURANT_ID, RESTO_QR_SECRET, AUTH_SECRETs

Env:
- GITEA_URL=https://git.dfmpanel.eu added to .env + .env.example
This commit is contained in:
Z User committed 2026-10-29 13:48:05 +00:00
1 parent aa075762b2
commit 89adb63f5e
6 files changed
+339 -10

No files matched your search

BIN
View File
Binary file not shown.
+40
View File
@@ -106,6 +106,7 @@ model Company {
tickets Ticket[] tickets Ticket[]
agencyApplication AgencyApplication? agencyApplication AgencyApplication?
instances Instance[] instances Instance[]
repoAssignments RepoAssignment[]
} }
model Person { model Person {
@@ -953,6 +954,8 @@ model Instance {
metadata String? // JSON: { gitRepo, gitBranch, imageTag, resources } metadata String? // JSON: { gitRepo, gitBranch, imageTag, resources }
lastError String? lastError String?
lastErrorAt DateTime? lastErrorAt DateTime?
allowedRepoId String?
allowedRepo AllowedRepo? @relation(fields: [allowedRepoId], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
events InstanceEvent[] events InstanceEvent[]
@@ -984,3 +987,40 @@ model ProvisioningJob {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
// ---------------------------------------------------------------------------
// Allowed Repos (admin controls which Gitea repos clients can deploy)
// ---------------------------------------------------------------------------
// Admin selects which repos are available for instance provisioning.
// Repos can be:
// - public: visible to all clients in the "Neue Instanz" dialog
// - assigned: only visible to specific companies (per-company assignment)
// Unfinished/custom projects are NOT shared unless explicitly assigned.
model AllowedRepo {
id String @id @default(cuid())
giteaOwner String // e.g. "dfmsolutions" (Gitea org/user)
giteaRepo String // e.g. "restov1" (repo name)
displayName String // human-readable: "Pizzeria RESTO (restov1)"
description String? // what this product does
product String // product slug: "restov1", "aos-cms", etc.
isPublic Boolean @default(false) // true = all clients can see it
isActive Boolean @default(true) // false = hidden from all
envTemplate String? // JSON: default env vars for this repo
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assignments RepoAssignment[]
instances Instance[] // instances created from this repo
@@unique([giteaOwner, giteaRepo])
}
model RepoAssignment {
id String @id @default(cuid())
allowedRepoId String
allowedRepo AllowedRepo @relation(fields: [allowedRepoId], references: [id], onDelete: Cascade)
companyId String // which company can use this repo
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([allowedRepoId, companyId])
}
+46 -9
View File
@@ -21,10 +21,8 @@ const GRACE_DAYS = 7
const createSchema = z.object({ const createSchema = z.object({
companyId: z.string().min(1), companyId: z.string().min(1),
licenseId: z.string().min(1), licenseId: z.string().min(1),
product: z.string().min(1).default('restov1'), allowedRepoId: z.string().min(1), // must reference an AllowedRepo
plan: z.enum(['starter', 'pro', 'enterprise']).default('pro'), plan: z.enum(['starter', 'pro', 'enterprise']).default('pro'),
gitRepo: z.string().url().default('https://github.com/cetinmustafa83/restov1'),
gitBranch: z.string().default('main'),
// Extra env vars to inject into the instance (beyond the standard ones) // Extra env vars to inject into the instance (beyond the standard ones)
envVars: z.record(z.string(), z.string()).optional(), envVars: z.record(z.string(), z.string()).optional(),
// If true, start as a demo (14-day expiry). If false, start as active (paid). // If true, start as a demo (14-day expiry). If false, start as active (paid).
@@ -72,7 +70,7 @@ export async function POST(req: Request) {
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
} }
const { companyId, licenseId, product, plan, gitRepo, gitBranch, envVars, isDemo } = parsed.data const { companyId, licenseId, allowedRepoId, plan, envVars, isDemo } = parsed.data
// Verify company + license exist and belong together // Verify company + license exist and belong together
const company = await db.company.findUnique({ where: { id: companyId } }) const company = await db.company.findUnique({ where: { id: companyId } })
@@ -83,9 +81,37 @@ export async function POST(req: Request) {
if (license.companyId !== companyId) { if (license.companyId !== companyId) {
return NextResponse.json({ error: 'license does not belong to this company' }, { status: 400 }) return NextResponse.json({ error: 'license does not belong to this company' }, { status: 400 })
} }
if (license.product !== product) {
return NextResponse.json({ error: 'license product does not match instance product' }, { status: 400 }) // --- Repo permission check ---
// Admin must have added this repo to the allowed list AND either:
// a) the repo is public (isPublic=true), OR
// b) the repo is assigned to this company (RepoAssignment exists)
const allowedRepo = await db.allowedRepo.findUnique({
where: { id: allowedRepoId },
include: {
assignments: { where: { companyId } },
},
})
if (!allowedRepo) {
return NextResponse.json({ error: 'repo not found in allowed list' }, { status: 404 })
} }
if (!allowedRepo.isActive) {
return NextResponse.json({ error: 'repo is disabled' }, { status: 403 })
}
const hasAccess = allowedRepo.isPublic || allowedRepo.assignments.length > 0
if (!hasAccess) {
return NextResponse.json({ error: 'repo is not assigned to this company and is not public' }, { status: 403 })
}
const product = allowedRepo.product
if (license.product !== product) {
return NextResponse.json({ error: `license product (${license.product}) does not match repo product (${product})` }, { status: 400 })
}
// Always use Gitea — git.dfmpanel.eu is our internal repo server
const GITEA_URL = process.env.GITEA_URL ?? 'https://git.dfmpanel.eu'
const gitRepo = `${GITEA_URL}/${allowedRepo.giteaOwner}/${allowedRepo.giteaRepo}.git`
const gitBranch = 'main' // always main
// Check that no live instance already exists for this license // Check that no live instance already exists for this license
const existing = await db.instance.findFirst({ const existing = await db.instance.findFirst({
@@ -119,6 +145,7 @@ export async function POST(req: Request) {
plan, plan,
subdomain, subdomain,
demoExpiresAt: isDemo ? new Date(Date.now() + DEMO_DAYS * 86400000) : null, demoExpiresAt: isDemo ? new Date(Date.now() + DEMO_DAYS * 86400000) : null,
allowedRepoId,
}, },
}) })
@@ -128,7 +155,7 @@ export async function POST(req: Request) {
type: 'provision.start', type: 'provision.start',
step: 'init', step: 'init',
result: 'ok', result: 'ok',
details: JSON.stringify({ subdomain, product, plan, isDemo, gitRepo }), details: JSON.stringify({ subdomain, product, plan, isDemo, gitRepo, allowedRepo: allowedRepo.displayName }),
}, },
}) })
@@ -141,14 +168,24 @@ export async function POST(req: Request) {
throw new Error('Dokploy client not configured (DOKPLOY_URL / DOKPLOY_API_KEY missing)') throw new Error('Dokploy client not configured (DOKPLOY_URL / DOKPLOY_API_KEY missing)')
} }
// Instance env vars: license key + AOS connection + product-specific // Parse repo-specific env template (if set by admin)
const repoEnvTemplate: Record<string, string> = allowedRepo.envTemplate
? JSON.parse(allowedRepo.envTemplate)
: {}
// Instance env vars: license + AOS + repo template + overrides
const instanceEnv: Record<string, string> = { const instanceEnv: Record<string, string> = {
// AOS license server connection
AOS_LICENSE_SERVER_URL: process.env.AOS_PUBLIC_URL ?? `https://${PLATFORM_DOMAIN}`, AOS_LICENSE_SERVER_URL: process.env.AOS_PUBLIC_URL ?? `https://${PLATFORM_DOMAIN}`,
AOS_PRODUCT: product, AOS_PRODUCT: product,
AOS_LICENSE_KEY: license.key, AOS_LICENSE_KEY: license.key,
AOS_LICENSE_DOMAIN: fullDomain, AOS_LICENSE_DOMAIN: fullDomain,
AOS_OFFLINE_GRACE_HOURS: '72', AOS_OFFLINE_GRACE_HOURS: '72',
// Standard runtime
NODE_ENV: 'production', NODE_ENV: 'production',
// Repo-specific defaults from admin (APP_MODE, AUTH_SECRET, etc.)
...repoEnvTemplate,
// Caller overrides (highest priority)
...envVars, ...envVars,
} }
@@ -158,7 +195,7 @@ export async function POST(req: Request) {
gitRepo, gitRepo,
gitBranch, gitBranch,
envVars: instanceEnv, envVars: instanceEnv,
gitProvider: gitRepo.includes('github.com') ? 'github' : 'gitea', gitProvider: 'gitea', // always Gitea
cpuLimit: plan === 'enterprise' ? '2' : plan === 'pro' ? '1' : '0.5', cpuLimit: plan === 'enterprise' ? '2' : plan === 'pro' ? '1' : '0.5',
memoryLimit: plan === 'enterprise' ? '2048m' : plan === 'pro' ? '1024m' : '512m', memoryLimit: plan === 'enterprise' ? '2048m' : plan === 'pro' ? '1024m' : '512m',
} }
+115
View File
@@ -0,0 +1,115 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
export const dynamic = 'force-dynamic'
const patchSchema = z.object({
displayName: z.string().optional(),
description: z.string().optional(),
isPublic: z.boolean().optional(),
isActive: z.boolean().optional(),
envTemplate: z.string().optional(),
// Assign to a specific company (or remove assignment)
assignToCompany: z.string().optional(),
unassignFromCompany: z.string().optional(),
})
// PATCH /api/aos/repos/[id] — update repo settings + assign/unassign
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const parsed = patchSchema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const { displayName, description, isPublic, isActive, envTemplate, assignToCompany, unassignFromCompany } = parsed.data
const repo = await db.allowedRepo.findUnique({ where: { id } })
if (!repo) {
return NextResponse.json({ error: 'repo not found' }, { status: 404 })
}
// Update fields
const updateData: Record<string, unknown> = {}
if (displayName !== undefined) updateData.displayName = displayName
if (description !== undefined) updateData.description = description
if (isPublic !== undefined) updateData.isPublic = isPublic
if (isActive !== undefined) updateData.isActive = isActive
if (envTemplate !== undefined) updateData.envTemplate = envTemplate
if (Object.keys(updateData).length > 0) {
await db.allowedRepo.update({ where: { id }, data: updateData })
}
// Assign to company
if (assignToCompany) {
await db.repoAssignment.upsert({
where: { allowedRepoId_companyId: { allowedRepoId: id, companyId: assignToCompany } },
create: { allowedRepoId: id, companyId: assignToCompany },
update: {},
})
}
// Unassign from company
if (unassignFromCompany) {
await db.repoAssignment.deleteMany({
where: { allowedRepoId: id, companyId: unassignFromCompany },
})
}
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'repo.update',
entity: 'AllowedRepo',
entityId: id,
after: JSON.stringify({ updateData, assignToCompany, unassignFromCompany }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ok: true })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// DELETE /api/aos/repos/[id] — remove repo from allowed list
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const repo = await db.allowedRepo.findUnique({
where: { id },
include: { _count: { select: { instances: true } } },
})
if (!repo) {
return NextResponse.json({ error: 'repo not found' }, { status: 404 })
}
if (repo._count.instances > 0) {
return NextResponse.json({
error: 'cannot delete repo with active instances',
instances: repo._count.instances,
}, { status: 409 })
}
await db.allowedRepo.delete({ where: { id } })
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'repo.delete',
entity: 'AllowedRepo',
entityId: id,
after: JSON.stringify({ giteaOwner: repo.giteaOwner, giteaRepo: repo.giteaRepo }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ok: true })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+110
View File
@@ -0,0 +1,110 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
export const dynamic = 'force-dynamic'
// GET /api/aos/repos — list all allowed repos (admin)
// GET /api/aos/repos?companyId=X — list repos available to a specific client
export async function GET(req: Request) {
try {
const url = new URL(req.url)
const companyId = url.searchParams.get('companyId')
if (companyId) {
// Client-scoped: show public repos + repos assigned to this company
const [publicRepos, assignedRepos] = await Promise.all([
db.allowedRepo.findMany({
where: { isPublic: true, isActive: true },
orderBy: { displayName: 'asc' },
}),
db.allowedRepo.findMany({
where: {
isActive: true,
assignments: { some: { companyId } },
},
include: { assignments: { where: { companyId } } },
orderBy: { displayName: 'asc' },
}),
])
// Merge + dedupe
const seen = new Set<string>()
const repos = [...publicRepos, ...assignedRepos].filter((r) => {
if (seen.has(r.id)) return false
seen.add(r.id)
return true
})
return NextResponse.json({ repos })
}
// Admin: list all repos
const repos = await db.allowedRepo.findMany({
orderBy: { createdAt: 'desc' },
include: {
_count: { select: { assignments: true, instances: true } },
},
})
return NextResponse.json({ repos })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
const createSchema = z.object({
giteaOwner: z.string().min(1),
giteaRepo: z.string().min(1),
displayName: z.string().min(1),
description: z.string().optional(),
product: z.string().min(1),
isPublic: z.boolean().default(false),
envTemplate: z.string().optional(), // JSON string of default env vars
})
// POST /api/aos/repos — admin adds a repo to the allowed list
export async function POST(req: Request) {
try {
const parsed = createSchema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const { giteaOwner, giteaRepo, displayName, description, product, isPublic, envTemplate } = parsed.data
const repo = await db.allowedRepo.upsert({
where: { giteaOwner_giteaRepo: { giteaOwner, giteaRepo } },
create: {
giteaOwner,
giteaRepo,
displayName,
description,
product,
isPublic,
envTemplate,
},
update: {
displayName,
description,
product,
isPublic,
envTemplate,
isActive: true,
},
})
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'repo.add',
entity: 'AllowedRepo',
entityId: repo.id,
after: JSON.stringify({ giteaOwner, giteaRepo, product, isPublic }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ repo }, { status: 201 })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+28 -1
View File
@@ -224,7 +224,34 @@ export async function POST(req: Request) {
stats.apiTokens = 0 stats.apiTokens = 0
} }
// --- 7. Audit log --- // --- 7. Allowed Repos (tenant instance provisioning) ---
const existingRepos = await db.allowedRepo.count()
if (existingRepos === 0) {
const restov1EnvTemplate = JSON.stringify({
APP_MODE: 'demo',
RESTO_RESTAURANT_ID: 'resto_demo_01',
RESTO_QR_SECRET: '4a27fcd1564d8557cfc8618c710476fe10f61c30e0896290567ac4656500b6d4',
CUSTOMER_AUTH_SECRET: '6dd5f89e224607ffc41668d1d590176af38dbbdb15d8eddea9ec1011604a260e',
STAFF_AUTH_SECRET: '8b09481986428134937be5685fe4bdc8858861e6a9b22f167506409bcd37ecc9',
})
await db.allowedRepo.create({
data: {
giteaOwner: 'dfmsolutions',
giteaRepo: 'restov1',
displayName: 'Pizzeria RESTO (restov1)',
description: 'Online-Bestellsystem für Restaurants — Pizza, Pasta, Lieferung, Reservierung',
product: 'restov1',
isPublic: true,
isActive: true,
envTemplate: restov1EnvTemplate,
},
})
stats.allowedRepos = 1
} else {
stats.allowedRepos = 0
}
// --- 8. Audit log ---
await db.auditLog.create({ await db.auditLog.create({
data: { data: {
actor: 'system:seed-prod', actor: 'system:seed-prod',