Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/src/api/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ import { eventSubscriptionFilterRoutes } from "./eventSubscriptionFilter.routes.
import { maintenanceRoutes } from "./maintenance.js";
import { notificationTemplatesRoutes } from "./notificationTemplates.js";
import { archivedDataBrowserRoutes } from "./archivedDataBrowser.routes.js";
import { providerAllowlistRoutes } from "./providerAllowlist.routes.js";
import { providerAllowlistAdminRoutes } from "./providerAllowlistAdmin.routes.js";

export async function registerRoutes(server: FastifyInstance) {
server.register(assetsRoutes, { prefix: "/api/v1/assets" });
Expand Down Expand Up @@ -122,4 +124,10 @@ export async function registerRoutes(server: FastifyInstance) {
prefix: "/api/v1/notification-templates",
});
server.register(archivedDataBrowserRoutes, { prefix: "/api/v1/archive" });
server.register(providerAllowlistRoutes, {
prefix: "/api/v1/providers/allowlist",
});
server.register(providerAllowlistAdminRoutes, {
prefix: "/api/v1/admin/providers/allowlist",
});
}
66 changes: 66 additions & 0 deletions backend/src/api/routes/providerAllowlist.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { providerAllowlistService } from "../../services/providerAllowlist.service.js";

export async function providerAllowlistRoutes(server: FastifyInstance) {
server.get(
"/",
{
schema: {
tags: ["Providers"],
summary: "List provider allowlist entries",
response: {
200: {
type: "object",
properties: {
enforcement: { type: "string", enum: ["open", "allowlist"] },
total: { type: "integer" },
entries: { type: "array", items: { type: "object", additionalProperties: true } },
},
},
},
},
},
async (_request: FastifyRequest, reply: FastifyReply) => {
const entries = await providerAllowlistService.listEntries();
const enforcement = entries.length > 0 ? "allowlist" : "open";
return reply.send({ enforcement, total: entries.length, entries });
}
);

server.get<{ Params: { providerKey: string } }>(
"/:providerKey",
{
schema: {
tags: ["Providers"],
summary: "Lookup provider allowlist status",
params: {
type: "object",
required: ["providerKey"],
properties: {
providerKey: { type: "string" },
},
},
response: {
200: {
type: "object",
properties: {
providerKey: { type: "string" },
allowed: { type: "boolean" },
enforcement: { type: "string", enum: ["open", "allowlist"] },
entry: { type: "object", nullable: true, additionalProperties: true },
},
},
},
},
},
async (request: FastifyRequest<{ Params: { providerKey: string } }>, reply: FastifyReply) => {
const { providerKey } = request.params;
const [entry, allowed] = await Promise.all([
providerAllowlistService.getEntry(providerKey),
providerAllowlistService.isAllowed(providerKey),
]);
const enforcement = allowed && !entry ? "open" : "allowlist";
return reply.send({ providerKey: providerKey.toLowerCase(), allowed, enforcement, entry });
}
);
}
105 changes: 105 additions & 0 deletions backend/src/api/routes/providerAllowlistAdmin.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { authMiddleware } from "../middleware/auth.js";
import { providerAllowlistService } from "../../services/providerAllowlist.service.js";

const providerKeySchema = z.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9-]*$/i);

const upsertBodySchema = z.object({
displayName: z.string().trim().min(1).max(120).optional(),
category: z.string().trim().min(1).max(80).optional(),
allowed: z.boolean(),
reason: z.string().trim().max(500).optional(),
});

export async function providerAllowlistAdminRoutes(server: FastifyInstance) {
const requireAdmin = authMiddleware({ requiredScopes: ["admin:config"] });

server.put<{ Params: { providerKey: string }; Body: z.infer<typeof upsertBodySchema> }>(
"/:providerKey",
{
preHandler: requireAdmin,
schema: {
tags: ["Config"],
summary: "Create or update a provider allowlist entry",
params: {
type: "object",
required: ["providerKey"],
properties: { providerKey: { type: "string" } },
},
body: {
type: "object",
required: ["allowed"],
properties: {
displayName: { type: "string" },
category: { type: "string" },
allowed: { type: "boolean" },
reason: { type: "string" },
},
},
response: {
200: { type: "object", properties: { entry: { type: "object", additionalProperties: true } } },
201: { type: "object", properties: { entry: { type: "object", additionalProperties: true } } },
},
},
},
async (request: FastifyRequest<{ Params: { providerKey: string }; Body: z.infer<typeof upsertBodySchema> }>, reply: FastifyReply) => {
const providerKey = providerKeySchema.parse(request.params.providerKey);
const body = upsertBodySchema.parse(request.body);

const existing = await providerAllowlistService.getEntry(providerKey);

const entry = await providerAllowlistService.upsertEntry({
providerKey,
displayName: body.displayName,
category: body.category,
allowed: body.allowed,
reason: body.reason ?? null,
actorId: request.apiKeyAuth?.id ?? request.apiKeyAuth?.name ?? "admin",
actorType: "api_key",
ipAddress: request.ip,
userAgent: request.headers["user-agent"] as string | undefined,
});

const status = existing ? 200 : 201;
return reply.code(status).send({ entry });
}
);

server.delete<{ Params: { providerKey: string } }>(
"/:providerKey",
{
preHandler: requireAdmin,
schema: {
tags: ["Config"],
summary: "Delete a provider allowlist entry",
params: {
type: "object",
required: ["providerKey"],
properties: { providerKey: { type: "string" } },
},
response: {
200: { type: "object", properties: { deleted: { type: "boolean" } } },
404: { $ref: "Error#" },
},
},
},
async (request: FastifyRequest<{ Params: { providerKey: string } }>, reply: FastifyReply) => {
const providerKey = providerKeySchema.parse(request.params.providerKey);

const deleted = await providerAllowlistService.deleteEntry({
providerKey,
actorId: request.apiKeyAuth?.id ?? request.apiKeyAuth?.name ?? "admin",
actorType: "api_key",
ipAddress: request.ip,
userAgent: request.headers["user-agent"] as string | undefined,
});

if (!deleted) {
return reply.code(404).send({ error: "Provider allowlist entry not found" });
}

return reply.send({ deleted: true });
}
);
}
22 changes: 22 additions & 0 deletions backend/src/database/migrations/027_provider_allowlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Knex } from "knex";

export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable("provider_allowlist", (table) => {
table.string("provider_key").primary();
table.string("display_name").notNullable();
table.string("category").notNullable().defaultTo("unknown");
table.boolean("allowed").notNullable().defaultTo(true);
table.text("reason").nullable();
table.string("created_by").notNullable();
table.timestamp("created_at").notNullable().defaultTo(knex.fn.now());
table.string("updated_by").notNullable();
table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now());

table.index(["category"]);
table.index(["allowed"]);
});
}

export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("provider_allowlist");
}
4 changes: 3 additions & 1 deletion backend/src/services/audit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
| "data.updated"
| "data.deleted"
| "admin.config_changed"
| "admin.provider_allowlist_changed"
| "admin.user_permission_changed"
| "admin.retention_policy_changed"
| "alert.rule_created"
Expand Down Expand Up @@ -96,7 +97,7 @@
before: entry.before,
after: entry.after,
severity: entry.severity,
});

Check failure

Code scanning / CodeQL

Use of password hash with insufficient computational effort High

Password from
an access to apiKeyAuth
is hashed insecurely.
Password from
an access to apiKeyAuth
is hashed insecurely.
Password from
an access to apiKeyAuth
is hashed insecurely.
Password from
an access to apiKeyAuth
is hashed insecurely.
Password from an access to apiKeyAuth is hashed insecurely.
Password from an access to apiKeyAuth is hashed insecurely.
Password from an access to apiKeyAuth is hashed insecurely.
Password from an access to apiKeyAuth is hashed insecurely.
Password from an access to apiKeyAuth is hashed insecurely.
return crypto.createHash("sha256").update(payload).digest("hex");
}

Expand Down Expand Up @@ -162,7 +163,7 @@
})
.returning("*");

logger.info(

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This logs sensitive data returned by
an access to apiKeyAuth
as clear text.
This logs sensitive data returned by
an access to apiKeyAuth
as clear text.
This logs sensitive data returned by
an access to apiKeyAuth
as clear text.
This logs sensitive data returned by
an access to apiKeyAuth
as clear text.
This logs sensitive data returned by an access to apiKeyAuth as clear text.
This logs sensitive data returned by an access to apiKeyAuth as clear text.
This logs sensitive data returned by an access to apiKeyAuth as clear text.
This logs sensitive data returned by an access to apiKeyAuth as clear text.
This logs sensitive data returned by an access to apiKeyAuth as clear text.
{ auditId: row.id, action: draft.action, actorId: draft.actorId, severity: draft.severity },
"Audit event recorded"
);
Expand Down Expand Up @@ -327,7 +328,8 @@
action === "admin.user_permission_changed" ||
action === "auth.api_key_revoked" ||
action === "webhook.secret_rotated" ||
action === "admin.config_changed"
action === "admin.config_changed" ||
action === "admin.provider_allowlist_changed"
) return "warning";

if (action === "admin.retention_policy_changed") return "critical";
Expand Down
Loading
Loading