Commit 64deba4d by krds-arun

implemented azure ad api

parent 8a959f9b
......@@ -13,12 +13,17 @@
"@fastify/compress": "^8.3.1",
"@fastify/cors": "^11.2.0",
"@fastify/multipart": "^10.0.0",
"bcrypt": "^6.0.0",
"fastify": "^5.6.1",
"ioredis": "^5.8.1",
"pg": "^8.16.3",
"jose": "^6.2.3",
"pg": "^8.21.0",
"pg-hstore": "^2.3.4",
"sequelize": "^6.37.8",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.10.0",
"@types/pg": "^8.20.0",
......
// Prisma 7 configuration. The CLI reads this to know where the schema lives
// and which adapter to use for `prisma db pull`, `prisma migrate`, etc.
// Runtime PrismaClient instantiation lives in src/db/prisma.ts and uses the
// same adapter pattern.
import "dotenv/config"; // optional; the fastify config already loads .env at runtime
import fs from "node:fs";
import path from "node:path";
import { defineConfig } from "prisma/config";
import { PrismaPg } from "@prisma/adapter-pg";
// Tiny .env loader so this file works standalone when `node:dotenv` isn't
// available (prisma CLI runs this file directly). Idempotent with the
// loader in src/config.ts.
(function ensureEnv() {
const envPath = path.resolve(process.cwd(), ".env");
if (!fs.existsSync(envPath)) return;
const text = fs.readFileSync(envPath, "utf8");
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
const k = trimmed.slice(0, eq).trim();
let v = trimmed.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.slice(1, -1);
}
if (process.env[k] === undefined) process.env[k] = v;
}
})();
const connectionString = process.env["POSTGRES_URL"]
?? "postgres://postgres:password@localhost:5432/maf-gateway";
export default defineConfig({
schema: "prisma/schema.prisma",
datasource: {
url: connectionString,
},
migrations: {
adapter: () => Promise.resolve(new PrismaPg({ connectionString })),
},
});
......@@ -35,6 +35,14 @@ export const config = {
schemaTtlSeconds: Number(process.env["CACHE_TTL_SCHEMA_SECONDS"] ?? 300),
masterTtlSeconds: Number(process.env["CACHE_TTL_MASTER_SECONDS"] ?? 180),
auth: {
jwtSecret: required(
"NEXTAUTH_SECRET",
// Dev-only fallback — production must set NEXTAUTH_SECRET to a 32+ byte secret.
"dev-only-please-replace-with-32+-byte-random-secret",
),
},
azure: {
accountName: required("AZURE_ACCOUNT_NAME", ""),
accountKey: required("AZURE_ACCOUNT_KEY", ""),
......
import pg from "pg";
import { config } from "../config.js";
const { Pool } = pg;
let _pool: pg.Pool | null = null;
export function getPool(): pg.Pool {
if (!_pool) {
_pool = new Pool({
connectionString: config.postgresUrl,
max: 30,
min: 5,
idleTimeoutMillis: 60_000,
connectionTimeoutMillis: 5_000,
allowExitOnIdle: false,
});
_pool.on("error", (err) => console.error("[pg-pool] idle client error", err));
}
return _pool;
}
/**
* Singleton Sequelize instance wired to the existing POSTGRES_URL.
* Existing pg-based repositories are untouched; this is available for new
* code or incremental migration off raw pg.
*/
import { Sequelize, QueryTypes, type Transaction } from "sequelize";
import { config } from "../config.js";
let _sequelize: Sequelize | null = null;
export function getSequelize(): Sequelize {
if (!_sequelize) {
_sequelize = new Sequelize(config.postgresUrl, {
dialect: "postgres",
logging: process.env["DEBUG_SEQUELIZE"] === "1" ? console.log : false,
pool: {
max: 30,
min: 5,
idle: 60_000,
acquire: 5_000,
},
define: {
timestamps: false,
underscored: true,
freezeTableName: true,
},
});
}
return _sequelize;
}
export const sequelize: Sequelize = getSequelize();
/**
* Drop-in replacement for the legacy `pg.Pool.query()` API, routed through
* Sequelize. Returns the same `{ rows, rowCount }` shape so existing
* repositories migrate without restructuring their result handling.
*
* SQL stays in Postgres-native `$1, $2` placeholder syntax via Sequelize's
* `bind` option — Sequelize forwards bound parameters directly to the pg
* driver so partition-pruning, jsonb, tsvector and other dialect-specific
* features continue to work.
*/
export async function query<T extends Record<string, unknown>>(
sql: string,
params: unknown[] = [],
options?: { transaction?: Transaction },
): Promise<{ rows: T[]; rowCount: number }> {
const trimmed = sql.trimStart().toUpperCase();
const isSelect =
trimmed.startsWith("SELECT") ||
trimmed.startsWith("WITH") ||
/\bRETURNING\b/i.test(sql);
if (isSelect) {
const rows = (await sequelize.query(sql, {
bind: params,
type: QueryTypes.SELECT,
transaction: options?.transaction,
})) as T[];
return { rows, rowCount: rows.length };
}
const [, affected] = (await sequelize.query(sql, {
bind: params,
transaction: options?.transaction,
})) as [unknown, number];
return { rows: [] as T[], rowCount: typeof affected === "number" ? affected : 0 };
}
/**
* Run `fn` inside a Sequelize transaction. The callback receives a `q`
* function with the same signature as the module-level `query` helper but
* scoped to the transaction.
*/
export async function withTransaction<T>(
fn: (q: typeof query) => Promise<T>,
): Promise<T> {
return sequelize.transaction(async (t) => {
const txQuery = <R extends Record<string, unknown>>(sql: string, params: unknown[] = []) =>
query<R>(sql, params, { transaction: t });
return fn(txQuery as typeof query);
});
}
import { Op } from "sequelize";
import {
Role,
SecurityEvent,
User,
UserPassword,
UserSsoAccount,
} from "../../db/models/auth.js";
export interface AuthenticatedUser {
id: string;
email: string;
username: string;
displayName: string | null;
avatarUrl: string | null;
roles: string[];
isActive: boolean;
isLocked: boolean;
}
function toAuthUser(u: User, roleCodes: string[]): AuthenticatedUser {
return {
id: u.id,
email: u.email,
username: u.username,
displayName: u.displayName ?? null,
avatarUrl: u.avatarUrl ?? null,
roles: roleCodes,
isActive: u.isActive,
isLocked: u.isLocked,
};
}
async function rolesForUserId(userId: string): Promise<string[]> {
const rows = await Role.findAll({
attributes: ["code", "name"],
include: [
{
association: Role.associations["users"]!,
attributes: [],
where: { id: userId },
required: true,
through: { attributes: [] },
},
],
});
return rows.map((r) => r.code ?? r.name);
}
export const authRepository = {
async findUserByEmail(email: string): Promise<User | null> {
return User.findOne({
where: {
email: { [Op.iLike]: email },
isDeleted: false,
},
});
},
async findUserById(id: string): Promise<User | null> {
return User.findOne({ where: { id, isDeleted: false } });
},
async getPasswordHash(userId: string): Promise<string | null> {
const row = await UserPassword.findOne({ where: { userId } });
return row?.passwordHash ?? null;
},
async getRoleCodesForUser(userId: string): Promise<string[]> {
return rolesForUserId(userId);
},
async toAuthUser(u: User): Promise<AuthenticatedUser> {
const roles = await rolesForUserId(u.id);
return toAuthUser(u, roles);
},
async recordSuccessfulLogin(userId: string, ip: string | null): Promise<void> {
await User.update(
{
lastLoginAt: new Date(),
failedLoginCount: 0,
lastFailedLoginAt: null,
updatedAt: new Date(),
},
{ where: { id: userId } },
);
await SecurityEvent.create({
userId,
eventType: "login.success",
severity: "info",
details: ip ? { ip } : null,
});
},
async recordFailedLogin(userId: string, ip: string | null): Promise<void> {
const user = await User.findByPk(userId);
if (!user) return;
const next = user.failedLoginCount + 1;
const lockUntil = next >= 5
? new Date(Date.now() + 15 * 60_000)
: null;
await User.update(
{
failedLoginCount: next,
lastFailedLoginAt: new Date(),
lockedUntil: lockUntil,
isLocked: lockUntil !== null,
updatedAt: new Date(),
},
{ where: { id: userId } },
);
await SecurityEvent.create({
userId,
eventType: "login.failure",
severity: lockUntil ? "warn" : "info",
details: ip ? { ip, attempt: next } : { attempt: next },
});
},
async findOrLinkSsoAccount(args: {
provider: string;
providerUserId: string;
email: string;
displayName: string | null;
}): Promise<User> {
const link = await UserSsoAccount.findOne({
where: { provider: args.provider, providerUserId: args.providerUserId },
});
if (link) {
const u = await User.findByPk(link.userId);
if (u) return u;
}
// No SSO link yet — match by email to existing user, else create.
let user = await User.findOne({
where: { email: { [Op.iLike]: args.email }, isDeleted: false },
});
if (!user) {
user = await User.create({
email: args.email,
username: args.email,
displayName: args.displayName,
authProvider: args.provider,
isActive: true,
});
}
await UserSsoAccount.findOrCreate({
where: { provider: args.provider, providerUserId: args.providerUserId },
defaults: {
userId: user.id,
provider: args.provider,
providerUserId: args.providerUserId,
email: args.email,
},
});
return user;
},
};
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import {
issueJwt,
resolveSsoUser,
verifyPassword,
} from "./service.js";
const CredentialsBody = z.object({
email: z.string().email(),
password: z.string().min(1),
});
const SsoBody = z.object({
provider: z.string().min(1),
providerUserId: z.string().min(1),
email: z.string().email(),
displayName: z.string().nullable().optional(),
});
export async function registerAuthRoutes(app: FastifyInstance): Promise<void> {
// Used by NextAuth Credentials provider's authorize() callback.
app.post("/api/auth/credentials/verify", async (request, reply) => {
const parsed = CredentialsBody.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ message: "Invalid body" });
}
const ip = request.ip ?? null;
const user = await verifyPassword(parsed.data.email, parsed.data.password, ip);
if (!user) {
return reply.code(401).send({ message: "Invalid credentials" });
}
const token = await issueJwt(user);
return reply.send({ user, token });
});
// Used by NextAuth SSO providers' signIn() callback to resolve/create
// the local user record and mint a backend-trusted JWT.
app.post("/api/auth/sso/exchange", async (request, reply) => {
const parsed = SsoBody.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ message: "Invalid body" });
}
const ip = request.ip ?? null;
const user = await resolveSsoUser({
provider: parsed.data.provider,
providerUserId: parsed.data.providerUserId,
email: parsed.data.email,
displayName: parsed.data.displayName ?? null,
ip,
});
const token = await issueJwt(user);
return reply.send({ user, token });
});
// Health check that requires a valid JWT (useful from the frontend to
// verify the token round-trip end-to-end).
app.get("/api/auth/me", async (request, reply) => {
const claims = request.authUser;
if (!claims) return reply.code(401).send({ message: "Unauthenticated" });
return reply.send(claims);
});
}
import bcrypt from "bcrypt";
import { SignJWT, jwtVerify } from "jose";
import { config } from "../../config.js";
import { authRepository, type AuthenticatedUser } from "./repository.js";
const ISSUER = "maf-gateway";
const AUDIENCE = "maf-frontend";
const TOKEN_TTL_SECONDS = 60 * 60 * 8; // 8h
const secretKey = (): Uint8Array => new TextEncoder().encode(config.auth.jwtSecret);
export interface JwtClaims {
sub: string; // user id
email: string;
username: string;
displayName: string | null;
roles: string[];
iat: number;
exp: number;
iss: string;
aud: string;
}
/** Verify an email+password pair against the auth.user_passwords hash. */
export async function verifyPassword(
email: string,
password: string,
ip: string | null,
): Promise<AuthenticatedUser | null> {
const user = await authRepository.findUserByEmail(email);
if (!user) return null;
if (!user.isActive || user.isDeleted) return null;
if (user.isLocked && user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
return null;
}
const hash = await authRepository.getPasswordHash(user.id);
if (!hash) return null;
const ok = await bcrypt.compare(password, hash);
if (!ok) {
await authRepository.recordFailedLogin(user.id, ip);
return null;
}
await authRepository.recordSuccessfulLogin(user.id, ip);
return authRepository.toAuthUser(user);
}
/** Resolve (or create) the local user backing an external SSO login. */
export async function resolveSsoUser(args: {
provider: string;
providerUserId: string;
email: string;
displayName: string | null;
ip: string | null;
}): Promise<AuthenticatedUser> {
const u = await authRepository.findOrLinkSsoAccount({
provider: args.provider,
providerUserId: args.providerUserId,
email: args.email,
displayName: args.displayName,
});
await authRepository.recordSuccessfulLogin(u.id, args.ip);
return authRepository.toAuthUser(u);
}
export async function issueJwt(user: AuthenticatedUser): Promise<string> {
const now = Math.floor(Date.now() / 1000);
return new SignJWT({
email: user.email,
username: user.username,
displayName: user.displayName,
roles: user.roles,
})
.setProtectedHeader({ alg: "HS256" })
.setSubject(user.id)
.setIssuer(ISSUER)
.setAudience(AUDIENCE)
.setIssuedAt(now)
.setExpirationTime(now + TOKEN_TTL_SECONDS)
.sign(secretKey());
}
export async function verifyJwt(token: string): Promise<JwtClaims> {
const { payload } = await jwtVerify(token, secretKey(), {
issuer: ISSUER,
audience: AUDIENCE,
});
return payload as unknown as JwtClaims;
}
import type pg from "pg";
import { getPool } from "../../db/pg-pool.js";
import { query, withTransaction } from "../../db/sequelize.js";
import {
resolveDomain,
type CreateMasterInput,
......@@ -116,11 +115,6 @@ function buildSelectCols(cfg: DomainConfig): string {
// ─── Repository implementation ────────────────────────────────────────────────
export class PgMasterRepository implements MasterRepository {
private readonly pool: pg.Pool;
constructor() {
this.pool = getPool();
}
async list(domain: string, filter: MasterFilter): Promise<MasterPage> {
const cfg = resolveDomain(domain);
......@@ -165,7 +159,7 @@ export class PgMasterRepository implements MasterRepository {
const { clause, params } = wb.build();
// COUNT
const countResult = await this.pool.query<{ count: string }>(
const countResult = await query<{ count: string }>(
`SELECT COUNT(*) AS count FROM ${cfg.table} ${clause}`,
params
);
......@@ -176,7 +170,7 @@ export class PgMasterRepository implements MasterRepository {
const limit = filter.limit;
const offset = (page - 1) * limit;
const dataResult = await this.pool.query<Record<string, unknown>>(
const dataResult = await query<Record<string, unknown>>(
`SELECT ${cols} FROM ${cfg.table} ${clause}
ORDER BY sort_order ASC, ${nameCol} ASC
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
......@@ -195,7 +189,7 @@ export class PgMasterRepository implements MasterRepository {
const cfg = resolveDomain(domain);
const cols = buildSelectCols(cfg);
const result = await this.pool.query<Record<string, unknown>>(
const result = await query<Record<string, unknown>>(
`SELECT ${cols} FROM ${cfg.table} WHERE id = $1 AND deleted_at IS NULL`,
[id]
);
......@@ -233,7 +227,7 @@ export class PgMasterRepository implements MasterRepository {
const placeholders = colNames.map((_, i) => `$${i + 1}`).join(", ");
const cols = buildSelectCols(cfg);
const result = await this.pool.query<Record<string, unknown>>(
const result = await query<Record<string, unknown>>(
`INSERT INTO ${cfg.table} (${colNames.join(", ")})
VALUES (${placeholders})
RETURNING ${cols}`,
......@@ -280,7 +274,7 @@ export class PgMasterRepository implements MasterRepository {
const idIdx = setValues.length + 1;
const cols = buildSelectCols(cfg);
const result = await this.pool.query<Record<string, unknown>>(
const result = await query<Record<string, unknown>>(
`UPDATE ${cfg.table}
SET ${setClauses.join(", ")}
WHERE id = $${idIdx} AND deleted_at IS NULL
......@@ -294,7 +288,7 @@ export class PgMasterRepository implements MasterRepository {
async softDelete(domain: string, id: string): Promise<boolean> {
const cfg = resolveDomain(domain);
const result = await this.pool.query(
const result = await query(
`UPDATE ${cfg.table}
SET deleted_at = NOW()
WHERE id = $1 AND deleted_at IS NULL`,
......@@ -309,7 +303,7 @@ export class PgMasterRepository implements MasterRepository {
const cols = buildSelectCols(toCfg);
// Inner join through the junction table → only related, undeleted target rows.
const result = await this.pool.query<Record<string, unknown>>(
const result = await query<Record<string, unknown>>(
`SELECT ${cols}
FROM ${toCfg.table} t
INNER JOIN ${rel.table} j ON j.${rel.toColumn} = t.id
......@@ -324,26 +318,17 @@ export class PgMasterRepository implements MasterRepository {
const rel = resolveRelationship(fromDomain, relName);
const unique = [...new Set(toIds)];
const client = await this.pool.connect();
try {
await client.query("BEGIN");
await withTransaction(async (q) => {
// Replace-all semantics: drop existing edges, insert the new set.
await client.query(`DELETE FROM ${rel.table} WHERE ${rel.fromColumn} = $1`, [fromId]);
await q(`DELETE FROM ${rel.table} WHERE ${rel.fromColumn} = $1`, [fromId]);
if (unique.length > 0) {
// Multi-row insert with one parameter per (fromId, toId) pair.
const placeholders = unique.map((_, i) => `($1, $${i + 2})`).join(", ");
await client.query(
await q(
`INSERT INTO ${rel.table} (${rel.fromColumn}, ${rel.toColumn}) VALUES ${placeholders}
ON CONFLICT DO NOTHING`,
[fromId, ...unique],
);
}
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
});
}
}
import type { FastifyInstance } from "fastify";
import { Pool } from "pg";
import { z } from "zod";
import { config } from "../../config.js";
import { query } from "../../db/sequelize.js";
import { uploadToAzure } from "./azure-upload.js";
const pool = new Pool({ connectionString: config.postgresUrl });
const submitSchema = z.object({
form_id: z.string().min(1),
data: z.record(z.string(), z.unknown()),
......@@ -38,13 +35,13 @@ export async function registerPocRoutes(app: FastifyInstance): Promise<void> {
const { form_id, data, files } = parsed.data;
try {
const result = await pool.query<{ id: string; submitted_at: string }>(
const result = await query<{ id: string; submitted_at: string }>(
`INSERT INTO form_responses (form_id, payload, files)
VALUES ($1, $2, $3)
RETURNING id, submitted_at`,
[form_id, JSON.stringify(data), JSON.stringify(files)]
);
const row = result.rows[0];
const row = result.rows[0]!;
return reply.code(201).send({ id: row.id, submitted_at: row.submitted_at });
} catch (err) {
return reply.code(500).send({ message: `Submission failed: ${(err as Error).message}` });
......
import { randomUUID } from "node:crypto";
import type pg from "pg";
import { getPool } from "../../db/pg-pool.js";
import { query } from "../../db/sequelize.js";
import type {
AttachFileInput,
CreateSubmissionInput,
......@@ -93,17 +92,12 @@ function toEvent(row: Record<string, unknown>): SubmissionEventRecord {
// ─── Repository ───────────────────────────────────────────────────────────────
export class PgSubmissionRepository {
private readonly pool: pg.Pool;
constructor() {
this.pool = getPool();
}
/** Look up surveyJson for a form version — used by the score calculator. */
async getFormVersionSurveyJson(
formVersionId: string,
): Promise<Record<string, unknown> | null> {
const r = await this.pool.query<{ survey_json: Record<string, unknown> }>(
const r = await query<{ survey_json: Record<string, unknown> }>(
`SELECT survey_json FROM forms.form_version WHERE id = $1`,
[formVersionId],
);
......@@ -115,7 +109,7 @@ export class PgSubmissionRepository {
async create(data: CreateSubmissionInput): Promise<SubmissionRecord> {
const submissionCode = `SUB_${randomUUID().replace(/-/g, "").slice(0, 16).toUpperCase()}`;
const result = await this.pool.query<Record<string, unknown>>(
const result = await query<Record<string, unknown>>(
`INSERT INTO forms.submission (
submitted_at, form_id, form_version_id, schema_checksum, module_code,
submitted_by, submission_code, status, answers,
......@@ -168,14 +162,14 @@ export class PgSubmissionRepository {
params.push(submittedAt);
conditions.push(tsWindow("submitted_at", params.length));
}
const subResult = await this.pool.query<Record<string, unknown>>(
const subResult = await query<Record<string, unknown>>(
`SELECT * FROM forms.submission WHERE ${conditions.join(" AND ")} LIMIT 1`,
params,
);
if (subResult.rowCount === 0) return null;
const sub = toSubmission(subResult.rows[0]!);
const filesResult = await this.pool.query<Record<string, unknown>>(
const filesResult = await query<Record<string, unknown>>(
`SELECT * FROM forms.submission_file
WHERE submission_id = $1 AND ${tsWindow("submission_submitted_at", 2)} AND deleted_at IS NULL
ORDER BY uploaded_at DESC`,
......@@ -185,7 +179,7 @@ export class PgSubmissionRepository {
}
async getByCode(code: string): Promise<SubmissionWithFiles | null> {
const subResult = await this.pool.query<Record<string, unknown>>(
const subResult = await query<Record<string, unknown>>(
`SELECT * FROM forms.submission
WHERE submission_code = $1 AND deleted_at IS NULL
ORDER BY submitted_at DESC LIMIT 1`,
......@@ -194,7 +188,7 @@ export class PgSubmissionRepository {
if (subResult.rowCount === 0) return null;
const sub = toSubmission(subResult.rows[0]!);
const filesResult = await this.pool.query<Record<string, unknown>>(
const filesResult = await query<Record<string, unknown>>(
`SELECT * FROM forms.submission_file
WHERE submission_id = $1 AND submission_submitted_at = $2 AND deleted_at IS NULL
ORDER BY uploaded_at DESC`,
......@@ -240,7 +234,7 @@ export class PgSubmissionRepository {
vals.push(submittedAt);
const idIdx = vals.length - 1;
const tsIdx = vals.length;
const result = await this.pool.query<Record<string, unknown>>(
const result = await query<Record<string, unknown>>(
`UPDATE forms.submission SET ${sets.join(", ")}
WHERE id = $${idIdx} AND ${tsWindow("submitted_at", tsIdx)}
AND deleted_at IS NULL
......@@ -252,7 +246,7 @@ export class PgSubmissionRepository {
}
async softDelete(id: string, submittedAt: string): Promise<boolean> {
const result = await this.pool.query(
const result = await query(
`UPDATE forms.submission
SET deleted_at = NOW(), updated_at = NOW()
WHERE id = $1 AND ${tsWindow("submitted_at", 2)} AND deleted_at IS NULL`,
......@@ -299,7 +293,7 @@ export class PgSubmissionRepository {
const whereClause = "WHERE " + conditions.join(" AND ");
const countResult = await this.pool.query<{ count: string }>(
const countResult = await query<{ count: string }>(
`SELECT COUNT(*) AS count FROM forms.submission ${whereClause}`,
params,
);
......@@ -307,7 +301,7 @@ export class PgSubmissionRepository {
const limit = filter.limit;
const offset = (filter.page - 1) * limit;
const dataResult = await this.pool.query<Record<string, unknown>>(
const dataResult = await query<Record<string, unknown>>(
`SELECT * FROM forms.submission
${whereClause}
ORDER BY submitted_at DESC
......@@ -330,7 +324,7 @@ export class PgSubmissionRepository {
submissionSubmittedAt: string,
file: AttachFileInput,
): Promise<SubmissionFileRecord> {
const result = await this.pool.query<Record<string, unknown>>(
const result = await query<Record<string, unknown>>(
`INSERT INTO forms.submission_file (
submission_id, submission_submitted_at,
question_code, file_name, blob_path, blob_container,
......@@ -358,7 +352,7 @@ export class PgSubmissionRepository {
submissionId: string,
submissionSubmittedAt: string,
): Promise<SubmissionFileRecord[]> {
const result = await this.pool.query<Record<string, unknown>>(
const result = await query<Record<string, unknown>>(
`SELECT * FROM forms.submission_file
WHERE submission_id = $1 AND ${tsWindow("submission_submitted_at", 2)} AND deleted_at IS NULL
ORDER BY uploaded_at DESC`,
......@@ -371,7 +365,7 @@ export class PgSubmissionRepository {
fileId: string,
submissionSubmittedAt: string,
): Promise<SubmissionFileRecord | null> {
const result = await this.pool.query<Record<string, unknown>>(
const result = await query<Record<string, unknown>>(
`SELECT * FROM forms.submission_file
WHERE id = $1 AND ${tsWindow("submission_submitted_at", 2)} AND deleted_at IS NULL
LIMIT 1`,
......@@ -385,7 +379,7 @@ export class PgSubmissionRepository {
fileId: string,
submissionSubmittedAt: string,
): Promise<boolean> {
const result = await this.pool.query(
const result = await query(
`UPDATE forms.submission_file
SET deleted_at = NOW()
WHERE id = $1 AND ${tsWindow("submission_submitted_at", 2)} AND deleted_at IS NULL`,
......@@ -403,7 +397,7 @@ export class PgSubmissionRepository {
actorUserId: string | null,
diff: Record<string, unknown> | null,
): Promise<SubmissionEventRecord> {
const result = await this.pool.query<Record<string, unknown>>(
const result = await query<Record<string, unknown>>(
`INSERT INTO forms.submission_event (
submission_id, submission_submitted_at,
event_type, actor_user_id, diff
......@@ -422,7 +416,7 @@ export class PgSubmissionRepository {
}
async listEvents(submissionId: string): Promise<SubmissionEventRecord[]> {
const result = await this.pool.query<Record<string, unknown>>(
const result = await query<Record<string, unknown>>(
`SELECT * FROM forms.submission_event
WHERE submission_id = $1
ORDER BY occurred_at DESC, id DESC
......
import type { SubmissionStatus } from "./domain.js";
// ─── State machine ────────────────────────────────────────────────────────────
//
// draft ──► submitted ──► approved
// │ │
// │ └────► rejected ──► draft (revise)
// │
// ▼
// archived (terminal — any non-approved row can be archived)
//
// approved ──► archived (terminal)
const TRANSITIONS: Record<SubmissionStatus, SubmissionStatus[]> = {
draft: ["submitted", "archived"],
......
import type { FastifyReply, FastifyRequest } from "fastify";
import { verifyJwt, type JwtClaims } from "../modules/auth/service.js";
const ALL_ROLES = ["Admin", "Form Builder", "Master Data Manager", "Read-only"] as const;
const WRITERS = ["Admin", "Form Builder"] as const;
const ADMINS = ["Admin"] as const;
const MDM_WRITE = ["Admin", "Master Data Manager"] as const;
declare module "fastify" {
interface FastifyRequest {
authUser?: JwtClaims;
}
}
/** Routes that don't require authentication. Anything else needs a valid JWT. */
const PUBLIC_PREFIXES = [
"/health",
"/api/auth/credentials/verify",
"/api/auth/sso/exchange",
];
/**
* Method-aware authorization matrix.
* Each entry: [HTTP method ("*" = any), URL prefix, allowed roles].
* Entries are evaluated in order — first match wins.
* Empty roles array [] = public route.
* Role-based authorization matrix. The roles match `auth.roles.code`
* (admin, super_admin, area_manager, …). `*` permits any authenticated
* caller; first matching entry wins.
*/
const MATRIX: Array<[string, string, readonly string[]]> = [
// Public
["*", "/health", []],
// Forms — read-only endpoints (GET)
["GET", "/api/forms/schema", ALL_ROLES],
["GET", "/api/forms", ALL_ROLES],
// Forms — write endpoints
["POST", "/api/forms/submissions", WRITERS],
["POST", "/api/forms", WRITERS],
["PATCH", "/api/forms", WRITERS],
["DELETE", "/api/forms", ADMINS],
// Submissions (separate module) — all authenticated roles can read
["GET", "/api/submissions", ALL_ROLES],
["POST", "/api/submissions", ALL_ROLES],
["PATCH", "/api/submissions", ALL_ROLES],
["DELETE", "/api/submissions", WRITERS],
// Master data — read
["GET", "/api/master-data", ALL_ROLES],
// Master data — write
["POST", "/api/master-data", MDM_WRITE],
["PATCH", "/api/master-data", MDM_WRITE],
["PUT", "/api/master-data", MDM_WRITE],
["DELETE", "/api/master-data", ADMINS],
const WRITER_ROLES = ["admin", "super_admin", "new_admin"] as const;
const ADMIN_ROLES = ["admin", "super_admin"] as const;
const MDM_WRITE = ["admin", "super_admin", "function_head"] as const;
const MATRIX: Array<[string, string, readonly string[] | "*"]> = [
// Forms
["GET", "/api/forms", "*"],
["POST", "/api/forms/submissions", WRITER_ROLES],
["POST", "/api/forms", WRITER_ROLES],
["PATCH", "/api/forms", WRITER_ROLES],
["DELETE", "/api/forms", ADMIN_ROLES],
// Submissions
["GET", "/api/submissions", "*"],
["POST", "/api/submissions", "*"],
["PATCH", "/api/submissions", "*"],
["DELETE", "/api/submissions", WRITER_ROLES],
// Master data
["GET", "/api/master-data", "*"],
["POST", "/api/master-data", MDM_WRITE],
["PATCH", "/api/master-data", MDM_WRITE],
["PUT", "/api/master-data", MDM_WRITE],
["DELETE", "/api/master-data", ADMIN_ROLES],
// Auth helpers
["GET", "/api/auth/me", "*"],
];
function isPublic(path: string): boolean {
return PUBLIC_PREFIXES.some((p) => path === p || path.startsWith(`${p}/`) || path.startsWith(`${p}?`));
}
function tokenFrom(request: FastifyRequest): string | null {
const header = request.headers["authorization"];
if (typeof header === "string" && header.toLowerCase().startsWith("bearer ")) {
return header.slice(7).trim();
}
return null;
}
function methodAllowed(method: string, path: string, roles: string[]): {
match: boolean;
allowed: boolean;
} {
for (const [entryMethod, prefix, allow] of MATRIX) {
if (!path.startsWith(prefix)) continue;
if (entryMethod !== method) continue;
if (allow === "*") return { match: true, allowed: true };
return {
match: true,
allowed: roles.some((r) => (allow as readonly string[]).includes(r)),
};
}
return { match: false, allowed: false };
}
export async function authorize(request: FastifyRequest, reply: FastifyReply): Promise<void> {
const role = (request.headers["x-maf-role"] as string | undefined) ?? "Read-only";
const method = request.method.toUpperCase();
const path = request.url.split("?")[0] ?? "";
const path = request.url.split("?")[0] ?? "";
for (const [entryMethod, prefix, allowed] of MATRIX) {
if (!path.startsWith(prefix)) continue;
if (entryMethod !== "*" && entryMethod !== method) continue;
if (isPublic(path)) return;
if (allowed.length === 0) return; // public
if ((allowed as string[]).includes(role)) return; // authorized
const token = tokenFrom(request);
if (!token) {
return void reply.code(401).send({ message: "Missing bearer token" });
}
let claims: JwtClaims;
try {
claims = await verifyJwt(token);
} catch {
return void reply.code(401).send({ message: "Invalid or expired token" });
}
request.authUser = claims;
const { match, allowed } = methodAllowed(method, path, claims.roles ?? []);
if (!match) {
return void reply.code(403).send({ message: "Forbidden: route not permitted" });
}
if (!allowed) {
return void reply.code(403).send({ message: "Forbidden for current role" });
}
return void reply.code(403).send({ message: "Forbidden: route not permitted" });
}
......@@ -8,15 +8,12 @@ import { registerFormRoutes } from "./modules/forms/routes.js";
import { registerMasterDataRoutes } from "./modules/masterData/routes.js";
import { registerPocRoutes } from "./modules/poc/routes.js";
import { registerSubmissionRoutes } from "./modules/submissions/routes.js";
import { registerAuthRoutes } from "./modules/auth/routes.js";
const app = Fastify({
logger: process.env["NODE_ENV"] !== "production",
disableRequestLogging: process.env["NODE_ENV"] === "production",
ajv: { customOptions: { removeAdditional: false } },
// SurveyJS form schemas (with many pages, choices, and inline help text)
// routinely exceed Fastify's 1 MB default. Bump to 16 MB so PATCH/POST of
// form definitions doesn't trip a 413 (which the browser surfaces as a
// CORS error because the request connection closes mid-flight).
bodyLimit: 16 * 1024 * 1024,
});
......@@ -27,7 +24,7 @@ await app.register(cors, {
"content-type",
"if-match",
"if-none-match",
"x-maf-role",
"authorization",
"x-tenant-id",
"x-confirm-delete-all",
],
......@@ -46,6 +43,7 @@ await app.register(multipart, { limits: { fileSize: 25 * 1024 * 1024 } });
app.addHook("preHandler", authorize);
await registerAuthRoutes(app);
await registerFormRoutes(app);
await registerMasterDataRoutes(app);
await registerPocRoutes(app);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment