feat: add cross-plugin secret references gated by the provider's RBAC permission

This commit is contained in:
LukeGus committed 2026-09-21 17:09:28 -05:00
1 parent 2d6c1cdecb
commit 9db66d5780
13 files changed
+1782 -10

No files matched your search

+50
View File
@@ -331,6 +331,56 @@
}
}
},
"providesSecret": {
"type": "array",
"description": "Secrets this plugin offers other plugins by reference. The value is never copied; a borrower re-reads it per call.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["key", "permission"],
"properties": {
"key": {
"type": "string",
"pattern": "^[a-z0-9-]+$",
"description": "Secret name within this plugin, e.g. \"api-key\"."
},
"permission": {
"type": "string",
"minLength": 1,
"description": "Role permission a borrowing user needs. Must also appear in contributes.permissionGroup.permissions."
},
"descriptionKey": {
"type": "string",
"minLength": 1,
"description": "i18n key describing the sharing, shown in the admin UI."
}
}
}
},
"requiresSecret": {
"type": "array",
"description": "Secrets this plugin borrows from another plugin. Reads resolve to null when the provider is absent or has nothing set.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["plugin", "key"],
"properties": {
"plugin": {
"type": "string",
"pattern": "^[a-z0-9-]+$",
"description": "Plugin id that offers the secret."
},
"key": {
"type": "string",
"pattern": "^[a-z0-9-]+$"
},
"optional": {
"type": "boolean",
"description": "When true, an absent provider is normal rather than reported as an unmet requirement."
}
}
}
},
"sidecars": {
"type": "array",
"items": {
+112
View File
@@ -18,6 +18,7 @@ const ID_PATTERN = /^[a-z0-9-]+$/;
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
const API_VERSION_PATTERN = /^[0-9]+$/;
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/;
// Dotted like a service name, but segments may be camelCase: these name a
// frontend function ("ai.openWithContext"), not a lowercase service contract.
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
@@ -132,6 +133,8 @@ function validateManifest(manifest, schema) {
errors.push(...validateProvides(manifest.provides));
errors.push(...validateRequires(manifest.requires));
errors.push(...validateProvidesSecret(manifest.providesSecret));
errors.push(...validateRequiresSecret(manifest.requiresSecret));
if (manifest.contributes && typeof manifest.contributes === "object") {
errors.push(...validateContributes(manifest.contributes));
@@ -150,6 +153,28 @@ function validateManifest(manifest, schema) {
}
}
// Same rule for a shared secret: sharing rides on the provider's own RBAC
// permission rather than a separate grant mechanism.
if (Array.isArray(manifest.providesSecret) && Array.isArray(declared)) {
for (const entry of manifest.providesSecret) {
if (entry && entry.permission && !declared.includes(entry.permission)) {
errors.push(
`Secret "${entry.key}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`,
);
}
}
}
if (Array.isArray(manifest.requiresSecret)) {
for (const entry of manifest.requiresSecret) {
if (entry && entry.plugin && entry.plugin === manifest.id) {
errors.push(
`requiresSecret entry for "${entry.key}" names this plugin itself; use ctx.secrets.get instead`,
);
}
}
}
// Same rule for UI actions: a permission the catalog never sees is one no
// admin can grant, so the action would be invisible rather than denied.
const actions = manifest.contributes?.actions;
@@ -257,6 +282,93 @@ function validateRequires(requires) {
return errors;
}
function validateProvidesSecret(providesSecret) {
if (providesSecret === undefined) return [];
if (!Array.isArray(providesSecret)) {
return ['Field "providesSecret" must be an array'];
}
const errors = [];
const seen = new Set();
providesSecret.forEach((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`providesSecret[${index}] must be an object`);
return;
}
if (typeof entry.key !== "string" || !SECRET_KEY_PATTERN.test(entry.key)) {
errors.push(
`providesSecret[${index}].key must match ${SECRET_KEY_PATTERN}, got: "${entry.key}"`,
);
} else if (seen.has(entry.key)) {
errors.push(
`providesSecret[${index}].key is a duplicate: "${entry.key}"`,
);
} else {
seen.add(entry.key);
}
if (typeof entry.permission !== "string" || entry.permission.length === 0) {
errors.push(`providesSecret[${index}].permission is required`);
}
if (
"descriptionKey" in entry &&
(typeof entry.descriptionKey !== "string" ||
entry.descriptionKey.length === 0)
) {
errors.push(`providesSecret[${index}].descriptionKey must be a string`);
}
});
return errors;
}
function validateRequiresSecret(requiresSecret) {
if (requiresSecret === undefined) return [];
if (!Array.isArray(requiresSecret)) {
return ['Field "requiresSecret" must be an array'];
}
const errors = [];
const seen = new Set();
requiresSecret.forEach((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`requiresSecret[${index}] must be an object`);
return;
}
if (typeof entry.plugin !== "string" || !ID_PATTERN.test(entry.plugin)) {
errors.push(
`requiresSecret[${index}].plugin must match ${ID_PATTERN}, got: "${entry.plugin}"`,
);
}
if (typeof entry.key !== "string" || !SECRET_KEY_PATTERN.test(entry.key)) {
errors.push(
`requiresSecret[${index}].key must match ${SECRET_KEY_PATTERN}, got: "${entry.key}"`,
);
}
if (typeof entry.plugin === "string" && typeof entry.key === "string") {
const id = `${entry.plugin}:${entry.key}`;
if (seen.has(id)) {
errors.push(`requiresSecret[${index}] is a duplicate: "${id}"`);
} else {
seen.add(id);
}
}
if ("optional" in entry && typeof entry.optional !== "boolean") {
errors.push(`requiresSecret[${index}].optional must be a boolean`);
}
});
return errors;
}
function validateContributes(contributes) {
const errors = [];