mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
fix(plugin): address scoped config review findings
This commit is contained in:
parent
db0ef46900
commit
06a9428a36
6 changed files with 19844 additions and 93 deletions
|
|
@ -2169,23 +2169,24 @@ export function pluginRoutes(
|
|||
res.status(400).json({ error: '"configJson" is required and must be an object' });
|
||||
return;
|
||||
}
|
||||
const configJson = body.configJson;
|
||||
const companyId = resolvePluginConfigCompanyId(req);
|
||||
|
||||
// Strip devUiUrl unless the caller is an instance admin. devUiUrl activates
|
||||
// a dev-proxy in the static file route that could be abused for SSRF if any
|
||||
// board-level user were allowed to set it.
|
||||
if (
|
||||
"devUiUrl" in body.configJson &&
|
||||
"devUiUrl" in configJson &&
|
||||
!(req.actor.type === "board" && req.actor.isInstanceAdmin)
|
||||
) {
|
||||
delete body.configJson.devUiUrl;
|
||||
delete configJson.devUiUrl;
|
||||
}
|
||||
|
||||
// Validate configJson against the plugin's instanceConfigSchema (if declared).
|
||||
// This ensures CLI/API callers get the same validation the UI performs client-side.
|
||||
const schema = plugin.manifestJson?.instanceConfigSchema;
|
||||
if (schema && Object.keys(schema).length > 0) {
|
||||
const validation = validateInstanceConfig(body.configJson, schema);
|
||||
const validation = validateInstanceConfig(configJson, schema);
|
||||
if (!validation.valid) {
|
||||
res.status(400).json({
|
||||
error: "Configuration does not match the plugin's instanceConfigSchema",
|
||||
|
|
@ -2196,30 +2197,45 @@ export function pluginRoutes(
|
|||
}
|
||||
|
||||
try {
|
||||
const secretRefsByPath = extractSecretRefPathsFromConfig(body.configJson, schema);
|
||||
const secretRefsByPath = extractSecretRefPathsFromConfig(configJson, schema);
|
||||
if (secretRefsByPath.size > 0 && !companyId) {
|
||||
res.status(422).json({ error: "Plugin secret references require companyId" });
|
||||
return;
|
||||
}
|
||||
if (companyId) {
|
||||
const refs = [...secretRefsByPath.entries()].flatMap(([secretId, paths]) =>
|
||||
[...paths].map((configPath) => ({ secretId, configPath })),
|
||||
);
|
||||
await secrets.syncSecretRefsForTarget(
|
||||
companyId,
|
||||
{ targetType: "plugin", targetId: plugin.id },
|
||||
refs,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await registry.upsertConfig(plugin.id, {
|
||||
configJson: body.configJson,
|
||||
}, companyId);
|
||||
const refs = [...secretRefsByPath.entries()].flatMap(([secretId, paths]) =>
|
||||
[...paths].map((configPath) => ({ secretId, configPath })),
|
||||
);
|
||||
const persistConfig = async (
|
||||
scopedSecrets: typeof secrets,
|
||||
scopedRegistry: typeof registry,
|
||||
secretDb?: Db,
|
||||
) => {
|
||||
if (companyId) {
|
||||
const target = { targetType: "plugin" as const, targetId: plugin.id };
|
||||
if (secretDb) {
|
||||
await scopedSecrets.syncSecretRefsForTarget(companyId, target, refs, { db: secretDb });
|
||||
} else {
|
||||
await scopedSecrets.syncSecretRefsForTarget(companyId, target, refs);
|
||||
}
|
||||
}
|
||||
return scopedRegistry.upsertConfig(plugin.id, {
|
||||
configJson,
|
||||
}, companyId);
|
||||
};
|
||||
const result = typeof db.transaction === "function"
|
||||
? await db.transaction((tx) =>
|
||||
persistConfig(
|
||||
secretService(tx as unknown as Db),
|
||||
pluginRegistryService(tx as unknown as Db),
|
||||
tx as unknown as Db,
|
||||
)
|
||||
)
|
||||
: await persistConfig(secrets, registry);
|
||||
await logPluginMutationActivity(req, "plugin.config.updated", plugin.id, {
|
||||
pluginId: plugin.id,
|
||||
pluginKey: plugin.pluginKey,
|
||||
companyId,
|
||||
configKeyCount: Object.keys(body.configJson).length,
|
||||
configKeyCount: Object.keys(configJson).length,
|
||||
});
|
||||
|
||||
// Only legacy/global config is still pushed into the process-global worker state.
|
||||
|
|
@ -2229,7 +2245,7 @@ export function pluginRoutes(
|
|||
await bridgeDeps.workerManager.call(
|
||||
plugin.id,
|
||||
"configChanged",
|
||||
{ config: body.configJson },
|
||||
{ config: configJson },
|
||||
);
|
||||
} catch (rpcErr) {
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ function isPluginKeyConflict(error: unknown): boolean {
|
|||
return err.code === "23505" && constraint === "plugins_plugin_key_idx";
|
||||
}
|
||||
|
||||
function pluginConfigScopeCondition(pluginId: string, companyId?: string | null) {
|
||||
function pluginConfigExactScopeCondition(pluginId: string, companyId?: string | null) {
|
||||
return and(
|
||||
eq(pluginConfig.pluginId, pluginId),
|
||||
companyId ? eq(pluginConfig.companyId, companyId) : isNull(pluginConfig.companyId),
|
||||
|
|
@ -288,12 +288,22 @@ export function pluginRegistryService(db: Db) {
|
|||
// ----- Config ---------------------------------------------------------
|
||||
|
||||
/** Retrieve a plugin's company-scoped config, or the legacy global fallback. */
|
||||
getConfig: (pluginId: string, companyId?: string | null) =>
|
||||
db
|
||||
getConfig: async (pluginId: string, companyId?: string | null) => {
|
||||
if (companyId) {
|
||||
const scoped = await db
|
||||
.select()
|
||||
.from(pluginConfig)
|
||||
.where(pluginConfigExactScopeCondition(pluginId, companyId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (scoped) return scoped;
|
||||
}
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(pluginConfig)
|
||||
.where(pluginConfigScopeCondition(pluginId, companyId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
.where(pluginConfigExactScopeCondition(pluginId, null))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create or fully replace a plugin's instance configuration.
|
||||
|
|
@ -307,7 +317,7 @@ export function pluginRegistryService(db: Db) {
|
|||
const existing = await db
|
||||
.select()
|
||||
.from(pluginConfig)
|
||||
.where(pluginConfigScopeCondition(pluginId, companyId))
|
||||
.where(pluginConfigExactScopeCondition(pluginId, companyId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (existing) {
|
||||
|
|
@ -318,7 +328,7 @@ export function pluginRegistryService(db: Db) {
|
|||
lastError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(pluginConfigScopeCondition(pluginId, companyId))
|
||||
.where(pluginConfigExactScopeCondition(pluginId, companyId))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
|
|
@ -345,7 +355,7 @@ export function pluginRegistryService(db: Db) {
|
|||
const existing = await db
|
||||
.select()
|
||||
.from(pluginConfig)
|
||||
.where(pluginConfigScopeCondition(pluginId, companyId))
|
||||
.where(pluginConfigExactScopeCondition(pluginId, companyId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (existing) {
|
||||
|
|
@ -357,7 +367,7 @@ export function pluginRegistryService(db: Db) {
|
|||
lastError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(pluginConfigScopeCondition(pluginId, companyId))
|
||||
.where(pluginConfigExactScopeCondition(pluginId, companyId))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
|
|
@ -381,7 +391,7 @@ export function pluginRegistryService(db: Db) {
|
|||
const rows = await db
|
||||
.update(pluginConfig)
|
||||
.set({ lastError, updatedAt: new Date() })
|
||||
.where(pluginConfigScopeCondition(pluginId, companyId))
|
||||
.where(pluginConfigExactScopeCondition(pluginId, companyId))
|
||||
.returning();
|
||||
|
||||
if (rows.length === 0) throw notFound("Plugin config not found");
|
||||
|
|
@ -392,7 +402,7 @@ export function pluginRegistryService(db: Db) {
|
|||
deleteConfig: async (pluginId: string, companyId?: string | null) => {
|
||||
const rows = await db
|
||||
.delete(pluginConfig)
|
||||
.where(pluginConfigScopeCondition(pluginId, companyId))
|
||||
.where(pluginConfigExactScopeCondition(pluginId, companyId))
|
||||
.returning();
|
||||
|
||||
return rows[0] ?? null;
|
||||
|
|
|
|||
|
|
@ -2034,6 +2034,7 @@ export function secretService(db: Db) {
|
|||
required?: boolean;
|
||||
label?: string | null;
|
||||
}>,
|
||||
options?: { db?: SecretBindingDb },
|
||||
) => {
|
||||
const normalizedRefs: Array<{
|
||||
secretId: string;
|
||||
|
|
@ -2042,8 +2043,9 @@ export function secretService(db: Db) {
|
|||
required: boolean;
|
||||
label: string | null;
|
||||
}> = [];
|
||||
const bindingDb = options?.db ?? db;
|
||||
for (const ref of refs) {
|
||||
await assertSecretInCompany(companyId, ref.secretId);
|
||||
await assertSecretInCompany(companyId, ref.secretId, bindingDb);
|
||||
normalizedRefs.push({
|
||||
secretId: ref.secretId,
|
||||
configPath: ref.configPath,
|
||||
|
|
@ -2055,10 +2057,10 @@ export function secretService(db: Db) {
|
|||
|
||||
const pathPrefixes = [...new Set(normalizedRefs.map((ref) => ref.configPath.split(".")[0]))];
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const writeBindings = async (targetDb: SecretBindingDb) => {
|
||||
if (pathPrefixes.length > 0) {
|
||||
for (const pathPrefix of pathPrefixes) {
|
||||
await tx
|
||||
await targetDb
|
||||
.delete(companySecretBindings)
|
||||
.where(
|
||||
and(
|
||||
|
|
@ -2070,7 +2072,7 @@ export function secretService(db: Db) {
|
|||
);
|
||||
}
|
||||
} else {
|
||||
await tx
|
||||
await targetDb
|
||||
.delete(companySecretBindings)
|
||||
.where(
|
||||
and(
|
||||
|
|
@ -2081,7 +2083,7 @@ export function secretService(db: Db) {
|
|||
);
|
||||
}
|
||||
if (normalizedRefs.length === 0) return;
|
||||
await tx.insert(companySecretBindings).values(
|
||||
await targetDb.insert(companySecretBindings).values(
|
||||
normalizedRefs.map((ref) => ({
|
||||
companyId,
|
||||
secretId: ref.secretId,
|
||||
|
|
@ -2093,7 +2095,13 @@ export function secretService(db: Db) {
|
|||
label: ref.label,
|
||||
})),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
if (options?.db) {
|
||||
await writeBindings(options.db);
|
||||
} else {
|
||||
await db.transaction(async (tx) => writeBindings(tx));
|
||||
}
|
||||
return normalizedRefs;
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue