mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(ql3): add secret-backed console automation
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
|
||||
const PROJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const TASK_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const SECRET_REF_PREFIX = 'qlsecret:v1:';
|
||||
const CRON_FIELD_PATTERN = /^[0-9A-Za-z*?,/#LW-]+$/;
|
||||
const TOKEN_PATTERN =
|
||||
/^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/;
|
||||
@@ -57,6 +59,11 @@
|
||||
'Trigger、Task 或授权在确认期间发生变化。请刷新后重新编辑。',
|
||||
invalid_trigger: '定时配置无效。请检查表达式、时区与 Task 状态。',
|
||||
trigger_unavailable: '定时配置暂时无法保存。请检查数据库状态。',
|
||||
secret_query_unavailable: 'Secret 元数据暂时不可读取。请检查数据库状态。',
|
||||
secret_fence_rejected:
|
||||
'Secret、凭据或授权在确认期间发生变化。请刷新后重新保存。',
|
||||
invalid_secret: 'Secret 名称、版本或明文无效。',
|
||||
secret_unavailable: 'Secret 暂时无法加密保存。请检查密钥与数据库状态。',
|
||||
run_cancellation_fence_rejected:
|
||||
'运行在确认期间发生变化,本次取消已安全拒绝。请刷新后重试。',
|
||||
request_unavailable: '本次请求没有完成,请确认服务仍在运行。',
|
||||
@@ -78,6 +85,7 @@
|
||||
refresh: document.getElementById('refresh-button'),
|
||||
createTask: document.getElementById('create-task-button'),
|
||||
createTrigger: document.getElementById('create-trigger-button'),
|
||||
createSecret: document.getElementById('create-secret-button'),
|
||||
dialog: document.getElementById('confirmation-dialog'),
|
||||
dialogTitle: document.getElementById('confirmation-title'),
|
||||
dialogCopy: document.getElementById('confirmation-copy'),
|
||||
@@ -94,6 +102,7 @@
|
||||
taskDescription: document.getElementById('task-description-input'),
|
||||
taskCommand: document.getElementById('task-command-input'),
|
||||
taskArgs: document.getElementById('task-args-input'),
|
||||
taskSecretBindings: document.getElementById('task-secret-bindings-input'),
|
||||
taskEnabled: document.getElementById('task-enabled-input'),
|
||||
taskEnabledLabel: document.getElementById('task-enabled-label'),
|
||||
triggerEditor: document.getElementById('trigger-editor-dialog'),
|
||||
@@ -108,6 +117,15 @@
|
||||
triggerTimezone: document.getElementById('trigger-timezone-input'),
|
||||
triggerMisfire: document.getElementById('trigger-misfire-input'),
|
||||
triggerEnabled: document.getElementById('trigger-enabled-input'),
|
||||
secretEditor: document.getElementById('secret-editor-dialog'),
|
||||
secretEditorTitle: document.getElementById('secret-editor-title'),
|
||||
secretEditorIntro: document.getElementById('secret-editor-intro'),
|
||||
secretEditorNote: document.getElementById('secret-editor-note'),
|
||||
secretEditorForm: document.getElementById('secret-editor-form'),
|
||||
secretEditorClose: document.getElementById('secret-editor-close'),
|
||||
secretEditorSave: document.getElementById('secret-editor-save'),
|
||||
secretName: document.getElementById('secret-name-input'),
|
||||
secretValue: document.getElementById('secret-value-input'),
|
||||
presenceDialog: document.getElementById('presence-dialog'),
|
||||
presenceForm: document.getElementById('presence-form'),
|
||||
presenceCopy: document.getElementById('presence-copy'),
|
||||
@@ -129,6 +147,8 @@
|
||||
pendingPresence: null,
|
||||
authoringSnapshot: null,
|
||||
triggerSnapshot: null,
|
||||
secretSnapshot: null,
|
||||
secretCatalog: [],
|
||||
toastTimer: null,
|
||||
};
|
||||
|
||||
@@ -223,6 +243,138 @@
|
||||
.join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}`;
|
||||
}
|
||||
|
||||
function encodeBase64UrlUtf8(value) {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return window
|
||||
.btoa(binary)
|
||||
.replace(/\+/gu, '-')
|
||||
.replace(/\//gu, '_')
|
||||
.replace(/=+$/gu, '');
|
||||
}
|
||||
|
||||
function decodeBase64UrlUtf8(value) {
|
||||
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/u.test(value)) {
|
||||
throw new TypeError('SecretRef 编码无效。');
|
||||
}
|
||||
const padding = '='.repeat((4 - (value.length % 4)) % 4);
|
||||
const binary = window.atob(
|
||||
value.replace(/-/gu, '+').replace(/_/gu, '/') + padding,
|
||||
);
|
||||
const bytes = Uint8Array.from(binary, (character) =>
|
||||
character.charCodeAt(0),
|
||||
);
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
}
|
||||
|
||||
function createSecretRef(projectId, name, version) {
|
||||
const payload = JSON.stringify({ projectId, name, version });
|
||||
const result = `${SECRET_REF_PREFIX}${encodeBase64UrlUtf8(payload)}`;
|
||||
if (result.length > 512) throw new TypeError('SecretRef 过长。');
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseSecretRef(value) {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length > 512 ||
|
||||
!value.startsWith(SECRET_REF_PREFIX)
|
||||
) {
|
||||
throw new TypeError('SecretRef 无效。');
|
||||
}
|
||||
const encoded = value.slice(SECRET_REF_PREFIX.length);
|
||||
const parsed = JSON.parse(decodeBase64UrlUtf8(encoded));
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed) ||
|
||||
Object.keys(parsed).sort().join(',') !== 'name,projectId,version' ||
|
||||
typeof parsed.projectId !== 'string' ||
|
||||
typeof parsed.name !== 'string' ||
|
||||
!Number.isSafeInteger(parsed.version) ||
|
||||
parsed.version < 1 ||
|
||||
createSecretRef(parsed.projectId, parsed.name, parsed.version) !== value
|
||||
) {
|
||||
throw new TypeError('SecretRef 无效。');
|
||||
}
|
||||
return Object.freeze(parsed);
|
||||
}
|
||||
|
||||
function isValidSecretName(value) {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
new TextEncoder().encode(value).length <= 128 &&
|
||||
!/[\u0000-\u001f\u007f]/u.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function secretBindingsFromTask(task) {
|
||||
const environment = task?.spec?.config?.environment;
|
||||
if (environment === undefined) return '';
|
||||
if (!Array.isArray(environment) || environment.length > 256) {
|
||||
throw new TypeError('Task 环境变量定义无效。');
|
||||
}
|
||||
return environment
|
||||
.filter((entry) => entry?.kind === 'secret')
|
||||
.map((entry) => {
|
||||
const reference = parseSecretRef(entry.secretRef);
|
||||
if (
|
||||
!ENVIRONMENT_NAME_PATTERN.test(entry.name) ||
|
||||
entry.name.startsWith('QL3_') ||
|
||||
reference.projectId !== state.project ||
|
||||
!TASK_PATTERN.test(reference.name)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'当前 Task 包含 Console 无法安全编辑的 Secret 绑定。',
|
||||
);
|
||||
}
|
||||
return `${entry.name}=${reference.name}@${reference.version}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function parseSecretBindings(value) {
|
||||
const entries = [];
|
||||
const names = new Set();
|
||||
const lines = value
|
||||
.split(/\r?\n/u)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
if (lines.length > 256) throw new TypeError('Secret 绑定不能超过 256 条。');
|
||||
for (const line of lines) {
|
||||
const match =
|
||||
/^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z0-9][A-Za-z0-9._:-]{0,127})(?:@([1-9][0-9]{0,9}))?$/u.exec(
|
||||
line,
|
||||
);
|
||||
if (!match || match[1].startsWith('QL3_') || names.has(match[1])) {
|
||||
throw new TypeError(`Secret 绑定无效:${line}`);
|
||||
}
|
||||
const available = state.secretCatalog.find(
|
||||
(secret) => secret.name === match[2],
|
||||
);
|
||||
const version = match[3] ? Number(match[3]) : available?.currentVersion;
|
||||
if (
|
||||
!available ||
|
||||
!Number.isSafeInteger(version) ||
|
||||
version < 1 ||
|
||||
version > available.currentVersion
|
||||
) {
|
||||
throw new TypeError(`找不到 Secret 当前版本:${match[2]}`);
|
||||
}
|
||||
names.add(match[1]);
|
||||
entries.push(
|
||||
Object.freeze({
|
||||
name: match[1],
|
||||
kind: 'secret',
|
||||
secretRef: createSecretRef(state.project, match[2], version),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Object.freeze(entries);
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
if (!state.token) {
|
||||
throw new ConsoleRequestError('authentication_required', 401, null);
|
||||
@@ -393,11 +545,17 @@
|
||||
nodes.taskEditorIntro.textContent = editing
|
||||
? `完整定义已由本机证明读取,并绑定 revision ${snapshot.task.revision}。保存时还会生成一份只绑定新内容的证明。`
|
||||
: '定义会先绑定到一次本机证明,再以同一事务写入 Task revision 与安全审计。';
|
||||
const secretHint =
|
||||
state.secretCatalog.length === 0
|
||||
? '当前没有可绑定 Secret;可先到“凭据”创建。'
|
||||
: `当前可绑定:${state.secretCatalog
|
||||
.map((secret) => `${secret.name}@${secret.currentVersion}`)
|
||||
.join('、')}`;
|
||||
nodes.taskEditorNote.textContent = editing
|
||||
? `编辑租约将在 ${formatTime(
|
||||
snapshot.authoring.expiresAtMs,
|
||||
)} 失效;关闭后重新选择“编辑任务”可取得新快照。`
|
||||
: 'Alpha 当前从 Console 创建 qinglong/command@v1;高级 Task schema 仍使用受信任管理入口。';
|
||||
)} 失效。${secretHint}`
|
||||
: `Console 创建 qinglong/command@v1。${secretHint}`;
|
||||
nodes.taskEnabledLabel.textContent = editing
|
||||
? '保存后允许运行'
|
||||
: '创建后允许运行';
|
||||
@@ -411,9 +569,11 @@
|
||||
nodes.taskDescription.value = snapshot.task.description || '';
|
||||
nodes.taskCommand.value = command.file;
|
||||
nodes.taskArgs.value = command.args.join('\n');
|
||||
nodes.taskSecretBindings.value = secretBindingsFromTask(snapshot.task);
|
||||
nodes.taskEnabled.checked = snapshot.task.enabled;
|
||||
} else {
|
||||
nodes.taskCommand.value = '/bin/echo';
|
||||
nodes.taskSecretBindings.value = '';
|
||||
nodes.taskEnabled.checked = true;
|
||||
}
|
||||
nodes.taskEditor.returnValue = '';
|
||||
@@ -462,6 +622,9 @@
|
||||
.split(/\r?\n/u)
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
const secretEnvironment = parseSecretBindings(
|
||||
nodes.taskSecretBindings.value,
|
||||
);
|
||||
if (!TASK_PATTERN.test(taskId)) {
|
||||
throw new TypeError('Task ID 格式无效。');
|
||||
}
|
||||
@@ -469,11 +632,24 @@
|
||||
throw new TypeError('名称、命令或参数数量无效。');
|
||||
}
|
||||
const snapshot = state.authoringSnapshot;
|
||||
const publicEnvironment = snapshot
|
||||
? (snapshot.task.spec.config.environment || []).filter(
|
||||
(entry) => entry?.kind === 'public',
|
||||
)
|
||||
: [];
|
||||
const environment = Object.freeze([
|
||||
...publicEnvironment,
|
||||
...secretEnvironment,
|
||||
]);
|
||||
if (environment.length > 256) {
|
||||
throw new TypeError('环境变量总数不能超过 256 条。');
|
||||
}
|
||||
const spec = snapshot
|
||||
? Object.freeze({
|
||||
...snapshot.task.spec,
|
||||
config: Object.freeze({
|
||||
...snapshot.task.spec.config,
|
||||
environment,
|
||||
command: Object.freeze({
|
||||
...snapshot.task.spec.config.command,
|
||||
kind: 'argv',
|
||||
@@ -486,6 +662,7 @@
|
||||
schema: 'qinglong/command@v1',
|
||||
config: Object.freeze({
|
||||
command: Object.freeze({ kind: 'argv', file, args }),
|
||||
environment,
|
||||
}),
|
||||
});
|
||||
return Object.freeze({
|
||||
@@ -622,6 +799,205 @@
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSecretMetadata(value) {
|
||||
const secrets = Array.isArray(value?.secrets) ? value.secrets : null;
|
||||
if (
|
||||
!secrets ||
|
||||
secrets.length > 64 ||
|
||||
secrets.some((secret) => {
|
||||
if (
|
||||
!secret ||
|
||||
!isValidSecretName(secret.name) ||
|
||||
!Number.isSafeInteger(secret.currentVersion) ||
|
||||
secret.currentVersion < 1 ||
|
||||
!Number.isSafeInteger(secret.createdAtMs) ||
|
||||
secret.createdAtMs < 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const reference = parseSecretRef(secret.secretRef);
|
||||
return (
|
||||
reference.projectId !== state.project ||
|
||||
reference.name !== secret.name ||
|
||||
reference.version !== secret.currentVersion
|
||||
);
|
||||
})
|
||||
) {
|
||||
throw new ConsoleRequestError('response_unavailable', 503, null);
|
||||
}
|
||||
return Object.freeze(secrets.map((secret) => Object.freeze(secret)));
|
||||
}
|
||||
|
||||
async function loadSecretCatalog() {
|
||||
const value = await api(
|
||||
`/api/v3/projects/${state.project}/secrets?limit=64`,
|
||||
);
|
||||
state.secretCatalog = normalizeSecretMetadata(value);
|
||||
return Object.freeze({
|
||||
secrets: state.secretCatalog,
|
||||
truncated: value.truncated === true,
|
||||
});
|
||||
}
|
||||
|
||||
function openSecretEditor(snapshot = null) {
|
||||
state.secretSnapshot = snapshot;
|
||||
nodes.secretEditorForm.reset();
|
||||
const rotating = snapshot !== null;
|
||||
nodes.secretEditorTitle.textContent = rotating
|
||||
? '轮换加密凭据'
|
||||
: '创建加密凭据';
|
||||
nodes.secretEditorIntro.textContent = rotating
|
||||
? `新值将写入 ${snapshot.name} 的 version ${
|
||||
snapshot.currentVersion + 1
|
||||
};已有 Task 仍固定使用旧版本。`
|
||||
: '明文只在当前页面内存和本次 loopback 请求中短暂存在;服务端只持久化 AES-256-GCM 密文。';
|
||||
nodes.secretEditorNote.textContent = rotating
|
||||
? '轮换不会悄悄改变现有自动化;请编辑 Task 明确切换到新版本。'
|
||||
: '保存需要一次性本机证明;API、审计、Console 与日志都不会返回明文。';
|
||||
nodes.secretName.readOnly = rotating;
|
||||
if (rotating) {
|
||||
nodes.secretName.setAttribute('aria-readonly', 'true');
|
||||
nodes.secretName.value = snapshot.name;
|
||||
} else {
|
||||
nodes.secretName.removeAttribute('aria-readonly');
|
||||
}
|
||||
nodes.secretValue.value = '';
|
||||
nodes.secretEditor.returnValue = '';
|
||||
nodes.secretEditor.showModal();
|
||||
(rotating ? nodes.secretValue : nodes.secretName).focus();
|
||||
}
|
||||
|
||||
function secretDraft() {
|
||||
const name = nodes.secretName.value.trim();
|
||||
const plaintext = nodes.secretValue.value;
|
||||
nodes.secretValue.value = '';
|
||||
if (!TASK_PATTERN.test(name)) {
|
||||
throw new TypeError('Secret 名称格式无效。');
|
||||
}
|
||||
if (!plaintext || new TextEncoder().encode(plaintext).length > 16 * 1024) {
|
||||
throw new TypeError('Secret 新值必须为 1–16384 bytes。');
|
||||
}
|
||||
return Object.freeze({
|
||||
name,
|
||||
plaintext,
|
||||
mutationId: newMutationId(),
|
||||
expectedCurrentVersion: state.secretSnapshot?.currentVersion || 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function saveSecretDraft() {
|
||||
let body;
|
||||
try {
|
||||
body = secretDraft();
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : 'Secret 定义无效。',
|
||||
'error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
nodes.secretEditorSave.disabled = true;
|
||||
try {
|
||||
const value = await api(`/api/v3/projects/${state.project}/secrets`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
acceptStatus: 428,
|
||||
});
|
||||
if (value.code === 'local_presence_required') {
|
||||
showPresenceChallenge({ kind: 'secret-mutation', body }, value);
|
||||
return;
|
||||
}
|
||||
throw new ConsoleRequestError('response_unavailable', 503, null);
|
||||
} catch (error) {
|
||||
body = null;
|
||||
showToast(describeError(error), 'error');
|
||||
} finally {
|
||||
nodes.secretEditorSave.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderSecrets() {
|
||||
const value = await loadSecretCatalog();
|
||||
if (value.secrets.length === 0) {
|
||||
empty('还没有加密 Secret。创建后可在命令 Task 中绑定固定版本。');
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(
|
||||
listHeader('Encrypted Secret catalog', value.secrets.length),
|
||||
);
|
||||
const list = element('div', 'record-list');
|
||||
for (const secret of value.secrets) {
|
||||
const button = element('button', 'record');
|
||||
button.type = 'button';
|
||||
button.dataset.identity = secret.name;
|
||||
if (state.selectedId === secret.name) {
|
||||
button.setAttribute('aria-current', 'true');
|
||||
}
|
||||
const main = element('span');
|
||||
main.append(element('span', 'record-title', secret.name));
|
||||
main.append(
|
||||
recordMeta([`version ${secret.currentVersion}`, 'AES-256-GCM']),
|
||||
);
|
||||
const side = element('span', 'record-side');
|
||||
const status = element('span', 'status', '已加密');
|
||||
status.dataset.tone = 'active';
|
||||
side.append(status);
|
||||
side.append(
|
||||
element('span', 'record-time', formatTime(secret.createdAtMs)),
|
||||
);
|
||||
button.append(main, side);
|
||||
button.addEventListener('click', () => selectSecret(secret.name));
|
||||
list.append(button);
|
||||
}
|
||||
fragment.append(list);
|
||||
if (value.truncated) {
|
||||
fragment.append(
|
||||
element(
|
||||
'p',
|
||||
'privacy-note',
|
||||
'当前只展示前 64 条;使用 API 可继续读取下一页。',
|
||||
),
|
||||
);
|
||||
}
|
||||
replace(nodes.ledger, fragment);
|
||||
}
|
||||
|
||||
function selectSecret(name) {
|
||||
state.selectedId = name;
|
||||
for (const row of nodes.ledger.querySelectorAll('.record')) {
|
||||
if (row.dataset.identity === name)
|
||||
row.setAttribute('aria-current', 'true');
|
||||
else row.removeAttribute('aria-current');
|
||||
}
|
||||
const secret = state.secretCatalog.find((entry) => entry.name === name);
|
||||
if (!secret) {
|
||||
detailEmpty('Secret 元数据已变化,请刷新后重试。');
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(detailHeader('Encrypted Secret', secret.name, secret.name));
|
||||
const facts = element('div', 'facts');
|
||||
facts.append(
|
||||
fact('当前版本', secret.currentVersion),
|
||||
fact('存储', 'AES-256-GCM 密文'),
|
||||
fact('Pinned ref', shortDigest(secret.secretRef)),
|
||||
fact('版本时间', formatTime(secret.createdAtMs)),
|
||||
);
|
||||
fragment.append(facts);
|
||||
const actions = element('div', 'detail-actions');
|
||||
actions.append(actionButton('轮换新版本', () => openSecretEditor(secret)));
|
||||
fragment.append(actions);
|
||||
fragment.append(
|
||||
element(
|
||||
'p',
|
||||
'privacy-note',
|
||||
'Task 只绑定固定版本;轮换后必须显式编辑 Task 才会采用新值。明文永不从此接口返回。',
|
||||
),
|
||||
);
|
||||
replace(nodes.detail, fragment);
|
||||
}
|
||||
|
||||
function showPresenceChallenge(action, challenge) {
|
||||
if (
|
||||
challenge?.code !== 'local_presence_required' ||
|
||||
@@ -634,13 +1010,20 @@
|
||||
state.pendingPresence = Object.freeze({ ...action, challenge });
|
||||
const authoringRead = action.kind === 'authoring';
|
||||
const triggerMutation = action.kind === 'trigger-mutation';
|
||||
const secretMutation = action.kind === 'secret-mutation';
|
||||
nodes.presenceCopy.textContent = authoringRead
|
||||
? '读取完整 Task 定义需要部署设备上的一次性证明。返回的编辑租约不替代保存时的新内容证明。'
|
||||
: triggerMutation
|
||||
? '使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Trigger 与 Task revision,且只能使用一次。'
|
||||
: secretMutation
|
||||
? '使用部署 QingLong 的系统用户读取下面的私有文件。证明绑定这次 Secret 内容摘要、当前版本和 User Credential,且只能使用一次。'
|
||||
: '使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Task 内容,且只能使用一次。';
|
||||
nodes.presenceSubmit.textContent = authoringRead
|
||||
? '验证并加载定义'
|
||||
: secretMutation
|
||||
? action.body.expectedCurrentVersion === 0
|
||||
? '验证并加密创建'
|
||||
: '验证并轮换版本'
|
||||
: action.mutation.body.expectedRevision === null
|
||||
? '验证并创建'
|
||||
: '验证并更新';
|
||||
@@ -653,6 +1036,7 @@
|
||||
nodes.presenceError.hidden = true;
|
||||
nodes.taskEditor.close();
|
||||
nodes.triggerEditor.close();
|
||||
nodes.secretEditor.close();
|
||||
nodes.presenceDialog.returnValue = '';
|
||||
nodes.presenceDialog.showModal();
|
||||
nodes.presenceProof.focus();
|
||||
@@ -735,6 +1119,15 @@
|
||||
state.pendingPresence = null;
|
||||
nodes.presenceProof.value = '';
|
||||
nodes.presenceDialog.close();
|
||||
try {
|
||||
await loadSecretCatalog();
|
||||
} catch {
|
||||
state.secretCatalog = [];
|
||||
showToast(
|
||||
'Task 已加载,但 Secret 目录暂不可用;已有绑定仍会保留。',
|
||||
'error',
|
||||
);
|
||||
}
|
||||
openTaskEditor(snapshot);
|
||||
showToast('完整 Task 定义已加载;保存仍需要新的本机证明。');
|
||||
return;
|
||||
@@ -767,6 +1160,32 @@
|
||||
await selectTrigger(pending.mutation.triggerId);
|
||||
return;
|
||||
}
|
||||
if (pending.kind === 'secret-mutation') {
|
||||
const value = await api(`/api/v3/projects/${state.project}/secrets`, {
|
||||
method: 'PUT',
|
||||
body: pending.body,
|
||||
presence: proof,
|
||||
});
|
||||
const rotated = pending.body.expectedCurrentVersion > 0;
|
||||
state.pendingPresence = null;
|
||||
state.secretSnapshot = null;
|
||||
nodes.secretValue.value = '';
|
||||
nodes.presenceProof.value = '';
|
||||
nodes.presenceDialog.close();
|
||||
showToast(
|
||||
value.status === 'existing'
|
||||
? '已找到同一 Secret 请求。'
|
||||
: rotated
|
||||
? `Secret 已轮换到 version ${value.secret.currentVersion}。`
|
||||
: 'Secret 已加密创建。',
|
||||
);
|
||||
state.view = 'secrets';
|
||||
state.selectedId = pending.body.name;
|
||||
updateNavigation();
|
||||
await refresh();
|
||||
selectSecret(pending.body.name);
|
||||
return;
|
||||
}
|
||||
const value = await api(
|
||||
`/api/v3/projects/${state.project}/tasks/${pending.mutation.taskId}`,
|
||||
{
|
||||
@@ -1326,6 +1745,7 @@
|
||||
if (state.view === 'tasks') {
|
||||
nodes.createTask.hidden = false;
|
||||
nodes.createTrigger.hidden = true;
|
||||
nodes.createSecret.hidden = true;
|
||||
nodes.kicker.textContent = 'Project task authority';
|
||||
nodes.title.textContent = '任务调度台';
|
||||
nodes.description.textContent =
|
||||
@@ -1333,13 +1753,23 @@
|
||||
} else if (state.view === 'triggers') {
|
||||
nodes.createTask.hidden = true;
|
||||
nodes.createTrigger.hidden = false;
|
||||
nodes.createSecret.hidden = true;
|
||||
nodes.kicker.textContent = 'Durable cron authority';
|
||||
nodes.title.textContent = '定时触发器';
|
||||
nodes.description.textContent =
|
||||
'配置内置 cron Trigger,绑定 Task 当前 revision;停用只追加历史,不删除证据。';
|
||||
} else if (state.view === 'secrets') {
|
||||
nodes.createTask.hidden = true;
|
||||
nodes.createTrigger.hidden = true;
|
||||
nodes.createSecret.hidden = false;
|
||||
nodes.kicker.textContent = 'Encrypted local custody';
|
||||
nodes.title.textContent = 'Secret 凭据库';
|
||||
nodes.description.textContent =
|
||||
'只展示名称和当前版本;明文经本机证明后加密保存,Task 显式绑定固定版本。';
|
||||
} else {
|
||||
nodes.createTask.hidden = true;
|
||||
nodes.createTrigger.hidden = true;
|
||||
nodes.createSecret.hidden = true;
|
||||
nodes.kicker.textContent = 'Durable run evidence';
|
||||
nodes.title.textContent = '运行事实账本';
|
||||
nodes.description.textContent =
|
||||
@@ -1353,6 +1783,7 @@
|
||||
try {
|
||||
if (state.view === 'tasks') await renderTasks();
|
||||
else if (state.view === 'triggers') await renderTriggers();
|
||||
else if (state.view === 'secrets') await renderSecrets();
|
||||
else await renderRuns();
|
||||
setConnection('connected', `${state.project} · 已连接`);
|
||||
} catch (error) {
|
||||
@@ -1387,8 +1818,11 @@
|
||||
state.pendingPresence = null;
|
||||
state.authoringSnapshot = null;
|
||||
state.triggerSnapshot = null;
|
||||
state.secretSnapshot = null;
|
||||
state.secretCatalog = [];
|
||||
if (nodes.taskEditor.open) nodes.taskEditor.close();
|
||||
if (nodes.triggerEditor.open) nodes.triggerEditor.close();
|
||||
if (nodes.secretEditor.open) nodes.secretEditor.close();
|
||||
if (nodes.presenceDialog.open) nodes.presenceDialog.close();
|
||||
nodes.token.value = '';
|
||||
nodes.token.disabled = false;
|
||||
@@ -1399,6 +1833,7 @@
|
||||
nodes.refresh.hidden = true;
|
||||
nodes.createTask.hidden = true;
|
||||
nodes.createTrigger.hidden = true;
|
||||
nodes.createSecret.hidden = true;
|
||||
setConnection('idle', '等待凭据');
|
||||
nodes.kicker.textContent = 'Connection gate';
|
||||
nodes.title.textContent = '先建立一条本机连接';
|
||||
@@ -1435,8 +1870,16 @@
|
||||
|
||||
nodes.disconnect.addEventListener('click', disconnect);
|
||||
nodes.refresh.addEventListener('click', refresh);
|
||||
nodes.createTask.addEventListener('click', () => openTaskEditor());
|
||||
nodes.createTask.addEventListener('click', async () => {
|
||||
try {
|
||||
await loadSecretCatalog();
|
||||
openTaskEditor();
|
||||
} catch (error) {
|
||||
showToast(describeError(error), 'error');
|
||||
}
|
||||
});
|
||||
nodes.createTrigger.addEventListener('click', () => openTriggerEditor());
|
||||
nodes.createSecret.addEventListener('click', () => openSecretEditor());
|
||||
nodes.taskEditorClose.addEventListener('click', () => {
|
||||
state.authoringSnapshot = null;
|
||||
nodes.taskEditor.close();
|
||||
@@ -1453,10 +1896,21 @@
|
||||
event.preventDefault();
|
||||
await saveTriggerDraft();
|
||||
});
|
||||
nodes.secretEditorClose.addEventListener('click', () => {
|
||||
state.secretSnapshot = null;
|
||||
nodes.secretValue.value = '';
|
||||
nodes.secretEditor.close();
|
||||
});
|
||||
nodes.secretEditorForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
await saveSecretDraft();
|
||||
});
|
||||
nodes.presenceCancel.addEventListener('click', () => {
|
||||
state.pendingPresence = null;
|
||||
state.authoringSnapshot = null;
|
||||
state.triggerSnapshot = null;
|
||||
state.secretSnapshot = null;
|
||||
nodes.secretValue.value = '';
|
||||
nodes.presenceProof.value = '';
|
||||
nodes.presenceDialog.close();
|
||||
});
|
||||
@@ -1495,6 +1949,7 @@
|
||||
nodes.dialog.open ||
|
||||
nodes.taskEditor.open ||
|
||||
nodes.triggerEditor.open ||
|
||||
nodes.secretEditor.open ||
|
||||
nodes.presenceDialog.open
|
||||
) {
|
||||
return;
|
||||
@@ -1504,6 +1959,8 @@
|
||||
? 'tasks'
|
||||
: event.key.toLowerCase() === 's'
|
||||
? 'triggers'
|
||||
: event.key.toLowerCase() === 'k'
|
||||
? 'secrets'
|
||||
: event.key.toLowerCase() === 'r'
|
||||
? 'runs'
|
||||
: null;
|
||||
|
||||
@@ -70,6 +70,9 @@
|
||||
<button type="button" data-view="triggers">
|
||||
<span>定时</span><kbd>S</kbd>
|
||||
</button>
|
||||
<button type="button" data-view="secrets">
|
||||
<span>凭据</span><kbd>K</kbd>
|
||||
</button>
|
||||
<button type="button" data-view="runs">
|
||||
<span>运行</span><kbd>R</kbd>
|
||||
</button>
|
||||
@@ -97,6 +100,9 @@
|
||||
<button class="action-button" id="create-trigger-button" type="button" hidden>
|
||||
<span aria-hidden="true">+</span> 创建定时
|
||||
</button>
|
||||
<button class="action-button" id="create-secret-button" type="button" hidden>
|
||||
<span aria-hidden="true">+</span> 创建凭据
|
||||
</button>
|
||||
<button class="refresh-button" id="refresh-button" type="button" hidden>
|
||||
<span aria-hidden="true">↻</span> 刷新
|
||||
</button>
|
||||
@@ -175,6 +181,10 @@
|
||||
<span>参数 · 每行一个</span>
|
||||
<textarea id="task-args-input" rows="5" maxlength="16384" spellcheck="false"></textarea>
|
||||
</label>
|
||||
<label class="editor-wide">
|
||||
<span>Secret 环境变量 · 每行 ENV=secret-name@version</span>
|
||||
<textarea id="task-secret-bindings-input" rows="4" maxlength="24576" spellcheck="false" placeholder="API_TOKEN=github-token"></textarea>
|
||||
</label>
|
||||
<label class="editor-check">
|
||||
<input id="task-enabled-input" type="checkbox" checked />
|
||||
<span id="task-enabled-label">创建后允许运行</span>
|
||||
@@ -243,6 +253,39 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="secret-editor-dialog" class="task-editor-dialog">
|
||||
<form id="secret-editor-form" autocomplete="off">
|
||||
<div class="dialog-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Encrypted Secret custody</p>
|
||||
<h2 id="secret-editor-title">创建加密凭据</h2>
|
||||
</div>
|
||||
<button class="quiet-button" id="secret-editor-close" type="button">关闭</button>
|
||||
</div>
|
||||
<p class="editor-intro" id="secret-editor-intro">
|
||||
明文只在当前页面内存和本次 loopback 请求中短暂存在;服务端只持久化 AES-256-GCM 密文。
|
||||
</p>
|
||||
<div class="editor-grid">
|
||||
<label class="editor-wide">
|
||||
<span>Secret 名称</span>
|
||||
<input id="secret-name-input" maxlength="128" spellcheck="false" autocomplete="off" required />
|
||||
</label>
|
||||
<label class="editor-wide">
|
||||
<span>新值</span>
|
||||
<input id="secret-value-input" type="password" maxlength="16384" spellcheck="false" autocomplete="new-password" required />
|
||||
</label>
|
||||
</div>
|
||||
<p class="editor-note" id="secret-editor-note">
|
||||
保存需要一次性本机证明;API、审计、Console 与日志都不会返回明文。
|
||||
</p>
|
||||
<div class="dialog-actions">
|
||||
<button class="primary-button" id="secret-editor-save" type="submit">
|
||||
加密保存并生成本机证明
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="presence-dialog" class="presence-dialog">
|
||||
<form id="presence-form" autocomplete="off">
|
||||
<p class="eyebrow">Local presence · 02:00</p>
|
||||
|
||||
@@ -33,6 +33,10 @@ import type {
|
||||
LocalApiTriggerReadRoute,
|
||||
} from '../trigger/triggerReadRoutes';
|
||||
import type { LocalApiTriggerPutRoute } from '../trigger/triggerPutRoute';
|
||||
import type {
|
||||
LocalApiSecretListRoute,
|
||||
LocalApiSecretPutRoute,
|
||||
} from '../secret/secretRoutes';
|
||||
import type { LocalApiResponse } from '../transport/contract';
|
||||
|
||||
export type LocalApiAdmissionOperation =
|
||||
@@ -111,6 +115,16 @@ export type LocalApiAdmissionOperation =
|
||||
operationId: 'trigger.put';
|
||||
projectId: string;
|
||||
triggerId: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
operationId: 'secret.list';
|
||||
projectId: string;
|
||||
limit: number;
|
||||
after?: Readonly<{ readonly name: string }>;
|
||||
}>
|
||||
| Readonly<{
|
||||
operationId: 'secret.put';
|
||||
projectId: string;
|
||||
}>;
|
||||
|
||||
export interface LocalApiAdmissionRequest {
|
||||
@@ -152,6 +166,8 @@ export interface LocalApiAdmissionOptions {
|
||||
readonly triggerListRoute: LocalApiTriggerListRoute;
|
||||
readonly triggerReadRoute: LocalApiTriggerReadRoute;
|
||||
readonly triggerPutRoute: LocalApiTriggerPutRoute;
|
||||
readonly secretListRoute: LocalApiSecretListRoute;
|
||||
readonly secretPutRoute: LocalApiSecretPutRoute;
|
||||
readonly now?: () => number;
|
||||
readonly randomUuid?: () => string;
|
||||
}
|
||||
@@ -235,6 +251,8 @@ export function createLocalApiAdmission(
|
||||
typeof options.triggerListRoute?.handle !== 'function' ||
|
||||
typeof options.triggerReadRoute?.handle !== 'function' ||
|
||||
typeof options.triggerPutRoute?.handle !== 'function' ||
|
||||
typeof options.secretListRoute?.handle !== 'function' ||
|
||||
typeof options.secretPutRoute?.handle !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.randomUuid !== undefined &&
|
||||
typeof options.randomUuid !== 'function')
|
||||
@@ -342,6 +360,24 @@ export function createLocalApiAdmission(
|
||||
});
|
||||
}
|
||||
|
||||
if (request.operation.operationId === 'secret.put') {
|
||||
const secretPutOperation = request.operation;
|
||||
return Object.freeze({
|
||||
bodyMode: 'json' as const,
|
||||
maximumBodyBytes: 20 * 1_024,
|
||||
async handle(body: unknown | null) {
|
||||
return options.secretPutRoute.handle({
|
||||
requestId: request.requestId,
|
||||
projectId: secretPutOperation.projectId,
|
||||
body,
|
||||
presence: request.localPresence,
|
||||
authenticated,
|
||||
signal: request.signal,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let decision: Readonly<SecurityPolicyDecision>;
|
||||
try {
|
||||
decision = normalizeSecurityPolicyDecision(
|
||||
@@ -359,6 +395,8 @@ export function createLocalApiAdmission(
|
||||
request.operation.operationId === 'trigger.list' ||
|
||||
request.operation.operationId === 'trigger.get'
|
||||
? 'task.read'
|
||||
: request.operation.operationId === 'secret.list'
|
||||
? 'secret.manage'
|
||||
: 'run.read',
|
||||
),
|
||||
);
|
||||
@@ -506,9 +544,19 @@ export function createLocalApiAdmission(
|
||||
projectId: request.operation.projectId,
|
||||
triggerId: request.operation.triggerId,
|
||||
});
|
||||
case 'secret.list':
|
||||
if (body !== null) return response(400, 'invalid_request_body');
|
||||
return options.secretListRoute.handle({
|
||||
projectId: request.operation.projectId,
|
||||
limit: request.operation.limit,
|
||||
...(request.operation.after
|
||||
? { after: request.operation.after }
|
||||
: {}),
|
||||
});
|
||||
case 'task.put':
|
||||
case 'task.authoring':
|
||||
case 'trigger.put':
|
||||
case 'secret.put':
|
||||
return response(503, 'request_unavailable');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
createLocalApiTriggerReadRoute,
|
||||
} from '../trigger/triggerReadRoutes';
|
||||
import { createLocalApiTriggerPutRoute } from '../trigger/triggerPutRoute';
|
||||
import {
|
||||
createLocalApiSecretListRoute,
|
||||
createLocalApiSecretPutRoute,
|
||||
} from '../secret/secretRoutes';
|
||||
import { startLocalApiHttpSurface } from '../transport/httpSurface';
|
||||
|
||||
export interface LocalApiProductSurfaceEvent {
|
||||
@@ -195,6 +199,28 @@ export function createLocalApiProductSurface(
|
||||
? {}
|
||||
: { randomUuid: options.randomUuid }),
|
||||
});
|
||||
const secretListRoute = createLocalApiSecretListRoute(
|
||||
authority.localSecretMetadata,
|
||||
);
|
||||
const secretPutRoute = createLocalApiSecretPutRoute({
|
||||
projectPolicy: authority.projectPolicy,
|
||||
secretAdministrationForCredential: (fence) => {
|
||||
if (fence.subjectType !== 'user') {
|
||||
throw new TypeError('Secret mutation requires a User credential');
|
||||
}
|
||||
return authority.localSecretAdministrationForCredential({
|
||||
...fence,
|
||||
subjectType: 'user',
|
||||
});
|
||||
},
|
||||
securityAudit: authority.securityAudit,
|
||||
secretKeys: authority.localSecretKeys,
|
||||
presenceProof,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
...(options.randomUuid === undefined
|
||||
? {}
|
||||
: { randomUuid: options.randomUuid }),
|
||||
});
|
||||
const admission = createLocalApiAdmission({
|
||||
authenticator,
|
||||
policy,
|
||||
@@ -213,6 +239,8 @@ export function createLocalApiProductSurface(
|
||||
triggerListRoute,
|
||||
triggerReadRoute,
|
||||
triggerPutRoute,
|
||||
secretListRoute,
|
||||
secretPutRoute,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
...(options.randomUuid === undefined
|
||||
? {}
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
LocalSecretAdministrationAuthenticationError,
|
||||
LocalSecretAdministrationAuthorizationError,
|
||||
LocalSecretAdministrationConfigurationError,
|
||||
LocalSecretAdministrationUnavailableError,
|
||||
createLocalSecretAdministrationService,
|
||||
} from '@qinglong/local-admin/secret-administration';
|
||||
import {
|
||||
LocalSecretMetadataUnavailableError,
|
||||
LocalSecretMutationConflictError,
|
||||
LocalSecretVersionConflictError,
|
||||
assertLocalSecretExpectedVersion,
|
||||
assertLocalSecretMutationId,
|
||||
assertLocalSecretName,
|
||||
assertLocalSecretPlaintext,
|
||||
assertLocalSecretProjectId,
|
||||
assertLocalSecretVersion,
|
||||
createLocalSecretRef,
|
||||
type LocalSecretMetadataPage,
|
||||
type LocalSecretKeyProvider,
|
||||
type LocalSecretMetadataSource,
|
||||
} from '@qinglong/runtime-core/local-secret';
|
||||
import {
|
||||
LocalSecretAuthorizationFenceConflictError,
|
||||
type LocalSecretAdministrationRepository,
|
||||
} from '@qinglong/runtime-core/local-secret-administration';
|
||||
import {
|
||||
ProjectPolicyEngine,
|
||||
ProjectPolicyUnavailableError,
|
||||
type ProjectPolicyRepository,
|
||||
} from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
normalizeSecurityPolicyDecision,
|
||||
type SecurityPolicyDecision,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditOutcome,
|
||||
type SecurityAuditSink,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
import type { AuthenticatedLocalApiRequest } from '../authentication/credentialAuthenticator';
|
||||
import {
|
||||
LocalPresenceProofUnavailableError,
|
||||
type LocalPresenceBinding,
|
||||
type LocalPresenceProofManager,
|
||||
} from '../authentication/localPresenceProof';
|
||||
import { strongLocalConsolePrincipal } from '../authentication/strongLocalPrincipal';
|
||||
import type { LocalApiResponse } from '../transport/contract';
|
||||
|
||||
const BODY_KEYS = Object.freeze([
|
||||
'expectedCurrentVersion',
|
||||
'mutationId',
|
||||
'name',
|
||||
'plaintext',
|
||||
]);
|
||||
|
||||
export interface LocalApiSecretListRoute {
|
||||
handle(request: LocalApiSecretListRequest): Promise<LocalApiResponse>;
|
||||
}
|
||||
|
||||
export interface LocalApiSecretListRequest {
|
||||
readonly projectId: string;
|
||||
readonly limit: number;
|
||||
readonly after?: Readonly<{ readonly name: string }>;
|
||||
}
|
||||
|
||||
export interface LocalApiSecretPutRequest {
|
||||
readonly requestId: string;
|
||||
readonly projectId: string;
|
||||
readonly body: unknown | null;
|
||||
readonly presence: string | null;
|
||||
readonly authenticated: Readonly<AuthenticatedLocalApiRequest>;
|
||||
readonly signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface LocalApiSecretPutRoute {
|
||||
handle(
|
||||
request: Readonly<LocalApiSecretPutRequest>,
|
||||
): Promise<LocalApiResponse>;
|
||||
}
|
||||
|
||||
export interface LocalApiSecretPutRouteOptions {
|
||||
readonly projectPolicy: ProjectPolicyRepository;
|
||||
readonly secretAdministrationForCredential: (
|
||||
fence: Readonly<AuthenticatedLocalApiRequest['credentialFence']>,
|
||||
) => Promise<LocalSecretAdministrationRepository>;
|
||||
readonly securityAudit: SecurityAuditSink;
|
||||
readonly secretKeys: LocalSecretKeyProvider;
|
||||
readonly presenceProof: LocalPresenceProofManager;
|
||||
readonly now?: () => number;
|
||||
readonly randomUuid?: () => string;
|
||||
}
|
||||
|
||||
type SecretPutCommand = Readonly<{
|
||||
name: string;
|
||||
plaintext: string;
|
||||
mutationId: string;
|
||||
expectedCurrentVersion: number;
|
||||
}>;
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): LocalApiResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === 'boolean' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'string'
|
||||
) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((entry) => canonicalJson(entry)).join(',')}]`;
|
||||
}
|
||||
const record = value as Readonly<Record<string, unknown>>;
|
||||
return `{${Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
|
||||
function normalizeBody(body: unknown | null): SecretPutCommand {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
throw new TypeError('Secret body is invalid');
|
||||
}
|
||||
const keys = Object.keys(body).sort();
|
||||
if (
|
||||
BODY_KEYS.some((key) => !keys.includes(key)) ||
|
||||
keys.some((key) => !BODY_KEYS.includes(key))
|
||||
) {
|
||||
throw new TypeError('Secret body shape is invalid');
|
||||
}
|
||||
const candidate = body as Record<string, unknown>;
|
||||
assertLocalSecretName(candidate.name);
|
||||
assertLocalSecretPlaintext(candidate.plaintext);
|
||||
assertLocalSecretMutationId(candidate.mutationId);
|
||||
assertLocalSecretExpectedVersion(candidate.expectedCurrentVersion);
|
||||
if (
|
||||
typeof candidate.mutationId !== 'string' ||
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(
|
||||
candidate.mutationId,
|
||||
)
|
||||
) {
|
||||
throw new TypeError('Secret mutation identity is invalid');
|
||||
}
|
||||
return Object.freeze(candidate as SecretPutCommand);
|
||||
}
|
||||
|
||||
function requestDigest(projectId: string, command: SecretPutCommand): string {
|
||||
return createHash('sha256')
|
||||
.update('qinglong3.local-api-secret-put.v1\0', 'utf8')
|
||||
.update(canonicalJson({ projectId, ...command }), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function presenceBinding(
|
||||
projectId: string,
|
||||
command: SecretPutCommand,
|
||||
authenticated: Readonly<AuthenticatedLocalApiRequest>,
|
||||
): Readonly<LocalPresenceBinding> {
|
||||
if (
|
||||
authenticated.principal.subject.type !== 'user' ||
|
||||
authenticated.credentialFence.subjectType !== 'user'
|
||||
) {
|
||||
throw new LocalPresenceProofUnavailableError(
|
||||
'strong User credential is required',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
requestDigest: requestDigest(projectId, command),
|
||||
credentialId: authenticated.credentialFence.credentialId,
|
||||
credentialVersion: authenticated.credentialFence.credentialVersion,
|
||||
subjectType: 'user',
|
||||
subjectId: authenticated.credentialFence.subjectId,
|
||||
});
|
||||
}
|
||||
|
||||
function timestamp(now: () => number): number {
|
||||
const value = now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new LocalPresenceProofUnavailableError('clock is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function recordAudit(
|
||||
audit: SecurityAuditSink,
|
||||
values: {
|
||||
readonly eventId: string;
|
||||
readonly requestId: string;
|
||||
readonly operationId: 'secret.create' | 'secret.rotate';
|
||||
readonly projectId: string;
|
||||
readonly authenticated: Readonly<AuthenticatedLocalApiRequest> | null;
|
||||
readonly outcome: SecurityAuditOutcome;
|
||||
readonly reasons: readonly string[];
|
||||
readonly fence: SecurityPolicyDecision['fence'];
|
||||
readonly occurredAtMs: number;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await audit.record(
|
||||
normalizeSecurityAuditRecord({
|
||||
eventId: values.eventId,
|
||||
requestId: values.requestId,
|
||||
operationId: values.operationId,
|
||||
projectId: values.projectId,
|
||||
subject: values.authenticated?.principal.subject ?? null,
|
||||
authenticationId:
|
||||
values.authenticated?.principal.authenticationId ?? null,
|
||||
outcome: values.outcome,
|
||||
reasons: values.reasons,
|
||||
fence: values.fence,
|
||||
occurredAtMs: values.occurredAtMs,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isCredentialFenceConflict(error: unknown): boolean {
|
||||
return (
|
||||
!!error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string' &&
|
||||
error.code.startsWith('LOCAL_SQLITE_AUTHENTICATED_')
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMetadataPage(
|
||||
request: Readonly<LocalApiSecretListRequest>,
|
||||
value: unknown,
|
||||
): Readonly<LocalSecretMetadataPage> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).some(
|
||||
(key) => !['next', 'secrets', 'truncated'].includes(key),
|
||||
)
|
||||
) {
|
||||
throw new LocalSecretMetadataUnavailableError();
|
||||
}
|
||||
const page = value as Readonly<Record<string, unknown>>;
|
||||
if (
|
||||
!Array.isArray(page.secrets) ||
|
||||
page.secrets.length > request.limit ||
|
||||
typeof page.truncated !== 'boolean'
|
||||
) {
|
||||
throw new LocalSecretMetadataUnavailableError();
|
||||
}
|
||||
assertLocalSecretProjectId(request.projectId);
|
||||
if (request.after) assertLocalSecretName(request.after.name);
|
||||
let previous = request.after?.name;
|
||||
const secrets = Object.freeze(
|
||||
page.secrets.map((candidate) => {
|
||||
if (
|
||||
!candidate ||
|
||||
typeof candidate !== 'object' ||
|
||||
Array.isArray(candidate) ||
|
||||
Object.keys(candidate).sort().join(',') !==
|
||||
'createdAtMs,currentVersion,name,projectId'
|
||||
) {
|
||||
throw new LocalSecretMetadataUnavailableError();
|
||||
}
|
||||
const secret = candidate as Readonly<Record<string, unknown>>;
|
||||
assertLocalSecretProjectId(secret.projectId);
|
||||
assertLocalSecretName(secret.name);
|
||||
assertLocalSecretVersion(secret.currentVersion);
|
||||
if (
|
||||
secret.projectId !== request.projectId ||
|
||||
!Number.isSafeInteger(secret.createdAtMs) ||
|
||||
(secret.createdAtMs as number) < 0 ||
|
||||
(previous !== undefined &&
|
||||
Buffer.compare(
|
||||
Buffer.from(secret.name as string, 'utf8'),
|
||||
Buffer.from(previous, 'utf8'),
|
||||
) <= 0)
|
||||
) {
|
||||
throw new LocalSecretMetadataUnavailableError();
|
||||
}
|
||||
previous = secret.name as string;
|
||||
return Object.freeze({
|
||||
projectId: secret.projectId as string,
|
||||
name: secret.name as string,
|
||||
currentVersion: secret.currentVersion as number,
|
||||
createdAtMs: secret.createdAtMs as number,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const next = page.next;
|
||||
if (
|
||||
page.truncated === true
|
||||
? !next ||
|
||||
typeof next !== 'object' ||
|
||||
Array.isArray(next) ||
|
||||
Object.keys(next).join('') !== 'name' ||
|
||||
(next as Readonly<Record<string, unknown>>).name !== previous
|
||||
: next !== undefined
|
||||
) {
|
||||
throw new LocalSecretMetadataUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
secrets,
|
||||
truncated: page.truncated,
|
||||
...(page.truncated === true && previous !== undefined
|
||||
? { next: Object.freeze({ name: previous }) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function createLocalApiSecretListRoute(
|
||||
source: LocalSecretMetadataSource,
|
||||
): Readonly<LocalApiSecretListRoute> {
|
||||
if (!source || typeof source.listLocalSecretMetadata !== 'function') {
|
||||
throw new TypeError('Local API Secret metadata source is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
async handle(request: Readonly<LocalApiSecretListRequest>) {
|
||||
try {
|
||||
const page = normalizeMetadataPage(
|
||||
request,
|
||||
await source.listLocalSecretMetadata(request),
|
||||
);
|
||||
return response(200, {
|
||||
secrets: Object.freeze(
|
||||
page.secrets.map((secret) =>
|
||||
Object.freeze({
|
||||
name: secret.name,
|
||||
currentVersion: secret.currentVersion,
|
||||
secretRef: createLocalSecretRef({
|
||||
projectId: secret.projectId,
|
||||
name: secret.name,
|
||||
version: secret.currentVersion,
|
||||
}),
|
||||
createdAtMs: secret.createdAtMs,
|
||||
}),
|
||||
),
|
||||
),
|
||||
truncated: page.truncated,
|
||||
...(page.next
|
||||
? {
|
||||
next: Object.freeze({
|
||||
after: Buffer.from(page.next.name, 'utf8').toString(
|
||||
'base64url',
|
||||
),
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch {
|
||||
return response(503, { code: 'secret_query_unavailable' });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createLocalApiSecretPutRoute(
|
||||
options: Readonly<LocalApiSecretPutRouteOptions>,
|
||||
): Readonly<LocalApiSecretPutRoute> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
typeof options.projectPolicy?.resolve !== 'function' ||
|
||||
typeof options.secretAdministrationForCredential !== 'function' ||
|
||||
typeof options.securityAudit?.record !== 'function' ||
|
||||
typeof options.secretKeys?.active !== 'function' ||
|
||||
typeof options.secretKeys?.resolve !== 'function' ||
|
||||
typeof options.presenceProof?.issue !== 'function' ||
|
||||
typeof options.presenceProof?.consume !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.randomUuid !== undefined &&
|
||||
typeof options.randomUuid !== 'function')
|
||||
) {
|
||||
throw new TypeError('Local API Secret put route options are invalid');
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const uuid = options.randomUuid ?? randomUUID;
|
||||
const policy = new ProjectPolicyEngine(options.projectPolicy);
|
||||
|
||||
return Object.freeze({
|
||||
async handle(request: Readonly<LocalApiSecretPutRequest>) {
|
||||
if (request.signal.aborted) {
|
||||
return response(503, { code: 'request_unavailable' });
|
||||
}
|
||||
let command: SecretPutCommand;
|
||||
try {
|
||||
command = normalizeBody(request.body);
|
||||
} catch {
|
||||
return response(400, { code: 'invalid_secret' });
|
||||
}
|
||||
const operationId =
|
||||
command.expectedCurrentVersion === 0
|
||||
? ('secret.create' as const)
|
||||
: ('secret.rotate' as const);
|
||||
let occurredAtMs: number;
|
||||
try {
|
||||
occurredAtMs = timestamp(now);
|
||||
} catch {
|
||||
return response(503, { code: 'local_presence_unavailable' });
|
||||
}
|
||||
let decision: Readonly<SecurityPolicyDecision>;
|
||||
try {
|
||||
decision = normalizeSecurityPolicyDecision(
|
||||
await policy.authorize(
|
||||
request.authenticated.principal,
|
||||
request.projectId,
|
||||
'secret.manage',
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const audited = await recordAudit(options.securityAudit, {
|
||||
eventId: uuid(),
|
||||
requestId: request.requestId,
|
||||
operationId,
|
||||
projectId: request.projectId,
|
||||
authenticated: request.authenticated,
|
||||
outcome: 'authorization_unavailable',
|
||||
reasons: ['policy_unavailable'],
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
});
|
||||
return response(503, {
|
||||
code:
|
||||
audited && error instanceof ProjectPolicyUnavailableError
|
||||
? 'authorization_unavailable'
|
||||
: 'security_audit_unavailable',
|
||||
});
|
||||
}
|
||||
if (decision.effect !== 'allow') {
|
||||
const audited = await recordAudit(options.securityAudit, {
|
||||
eventId: uuid(),
|
||||
requestId: request.requestId,
|
||||
operationId,
|
||||
projectId: request.projectId,
|
||||
authenticated: request.authenticated,
|
||||
outcome:
|
||||
decision.effect === 'require_approval'
|
||||
? 'approval_required'
|
||||
: 'denied',
|
||||
reasons: decision.reasons,
|
||||
fence: decision.fence,
|
||||
occurredAtMs,
|
||||
});
|
||||
if (!audited) {
|
||||
return response(503, { code: 'security_audit_unavailable' });
|
||||
}
|
||||
return response(403, {
|
||||
code:
|
||||
decision.effect === 'require_approval'
|
||||
? 'approval_required'
|
||||
: 'forbidden',
|
||||
});
|
||||
}
|
||||
let binding: Readonly<LocalPresenceBinding>;
|
||||
try {
|
||||
binding = presenceBinding(
|
||||
request.projectId,
|
||||
command,
|
||||
request.authenticated,
|
||||
);
|
||||
} catch {
|
||||
return response(401, { code: 'strong_authentication_required' });
|
||||
}
|
||||
if (!request.presence) {
|
||||
let challenge;
|
||||
try {
|
||||
challenge = options.presenceProof.issue(binding);
|
||||
} catch {
|
||||
return response(503, { code: 'local_presence_unavailable' });
|
||||
}
|
||||
const audited = await recordAudit(options.securityAudit, {
|
||||
eventId: uuid(),
|
||||
requestId: request.requestId,
|
||||
operationId,
|
||||
projectId: request.projectId,
|
||||
authenticated: request.authenticated,
|
||||
outcome: 'approval_required',
|
||||
reasons: ['local_presence_required'],
|
||||
fence: decision.fence,
|
||||
occurredAtMs,
|
||||
});
|
||||
if (!audited) {
|
||||
return response(503, { code: 'security_audit_unavailable' });
|
||||
}
|
||||
return response(428, {
|
||||
code: 'local_presence_required',
|
||||
authorizationId: challenge.authorizationId,
|
||||
requestDigest: challenge.requestDigest,
|
||||
expiresAtMs: challenge.expiresAtMs,
|
||||
proofFileName: challenge.proofFileName,
|
||||
});
|
||||
}
|
||||
let proof;
|
||||
try {
|
||||
await request.authenticated.confirm();
|
||||
proof = options.presenceProof.consume(request.presence, binding);
|
||||
} catch {
|
||||
return response(503, { code: 'authentication_unavailable' });
|
||||
}
|
||||
if (!proof) {
|
||||
const audited = await recordAudit(options.securityAudit, {
|
||||
eventId: uuid(),
|
||||
requestId: request.requestId,
|
||||
operationId,
|
||||
projectId: request.projectId,
|
||||
authenticated: null,
|
||||
outcome: 'authentication_rejected',
|
||||
reasons: ['local_presence_rejected'],
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
});
|
||||
return audited
|
||||
? response(401, { code: 'local_presence_rejected' })
|
||||
: response(503, { code: 'security_audit_unavailable' });
|
||||
}
|
||||
if (request.signal.aborted) {
|
||||
return response(503, { code: 'request_unavailable' });
|
||||
}
|
||||
try {
|
||||
const strongPrincipal = strongLocalConsolePrincipal(
|
||||
request.authenticated,
|
||||
proof,
|
||||
);
|
||||
const mutations = await options.secretAdministrationForCredential(
|
||||
request.authenticated.credentialFence,
|
||||
);
|
||||
const service = createLocalSecretAdministrationService(
|
||||
options.projectPolicy,
|
||||
mutations,
|
||||
options.securityAudit,
|
||||
options.secretKeys,
|
||||
{ now },
|
||||
);
|
||||
const result = await service.put({
|
||||
projectId: request.projectId,
|
||||
name: command.name,
|
||||
plaintext: command.plaintext,
|
||||
mutationId: command.mutationId,
|
||||
requestId: request.requestId,
|
||||
expectedCurrentVersion: command.expectedCurrentVersion,
|
||||
principal: strongPrincipal,
|
||||
});
|
||||
return response(
|
||||
result.status === 'inserted' && command.expectedCurrentVersion === 0
|
||||
? 201
|
||||
: 200,
|
||||
{
|
||||
status: result.status,
|
||||
secret: Object.freeze({
|
||||
name: command.name,
|
||||
currentVersion: result.version,
|
||||
secretRef: result.secretRef,
|
||||
}),
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof LocalSecretVersionConflictError ||
|
||||
error instanceof LocalSecretMutationConflictError ||
|
||||
error instanceof LocalSecretAuthorizationFenceConflictError ||
|
||||
isCredentialFenceConflict(error)
|
||||
) {
|
||||
return response(409, { code: 'secret_fence_rejected' });
|
||||
}
|
||||
if (error instanceof LocalSecretAdministrationAuthenticationError) {
|
||||
return response(401, { code: 'strong_authentication_required' });
|
||||
}
|
||||
if (error instanceof LocalSecretAdministrationAuthorizationError) {
|
||||
return response(403, { code: 'forbidden' });
|
||||
}
|
||||
if (error instanceof LocalSecretAdministrationConfigurationError) {
|
||||
return response(400, { code: 'invalid_secret' });
|
||||
}
|
||||
if (
|
||||
error instanceof LocalSecretAdministrationUnavailableError ||
|
||||
error instanceof LocalSecretMetadataUnavailableError
|
||||
) {
|
||||
return response(503, { code: 'secret_unavailable' });
|
||||
}
|
||||
return response(503, { code: 'secret_unavailable' });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type { BoundedRunListInput } from '@qinglong/runtime-core/bounded-run-lis
|
||||
import type { BoundedRunEventListInput } from '@qinglong/runtime-core/bounded-run-event-list-projection';
|
||||
import type { BoundedRunStepListInput } from '@qinglong/runtime-core/bounded-run-step-list-projection';
|
||||
import type { BoundedTaskListInput } from '@qinglong/runtime-core/bounded-task-list-projection';
|
||||
import { assertLocalSecretName } from '@qinglong/runtime-core/local-secret';
|
||||
import {
|
||||
loadLocalConsoleAssets,
|
||||
type LocalConsoleAsset,
|
||||
@@ -46,6 +47,8 @@ const TRIGGER_LIST_ROUTE_PATTERN =
|
||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/triggers$/;
|
||||
const TRIGGER_READ_ROUTE_PATTERN =
|
||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/triggers\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})$/;
|
||||
const SECRET_ROUTE_PATTERN =
|
||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/secrets$/;
|
||||
const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const LOCAL_CONSOLE_CONTENT_SECURITY_POLICY =
|
||||
@@ -60,7 +63,8 @@ type LocalApiRouteResolution =
|
||||
| 'invalid_run_step_list_query'
|
||||
| 'invalid_run_log_read_query'
|
||||
| 'invalid_task_list_query'
|
||||
| 'invalid_trigger_list_query';
|
||||
| 'invalid_trigger_list_query'
|
||||
| 'invalid_secret_list_query';
|
||||
}>;
|
||||
|
||||
export interface LocalApiHttpSurfaceOptions {
|
||||
@@ -445,6 +449,62 @@ function parseTriggerListQuery(
|
||||
});
|
||||
}
|
||||
|
||||
function parseSecretListQuery(
|
||||
rawQuery: string | undefined,
|
||||
profile: LocalApplicationProfile,
|
||||
): Readonly<{
|
||||
limit: number;
|
||||
after?: Readonly<{ readonly name: string }>;
|
||||
}> {
|
||||
if (rawQuery === undefined) {
|
||||
return Object.freeze({ limit: profile === 'edge' ? 16 : 32 });
|
||||
}
|
||||
if (rawQuery.length === 0) throw new TypeError();
|
||||
const values = new Map<string, string>();
|
||||
for (const field of rawQuery.split('&')) {
|
||||
const separator = field.indexOf('=');
|
||||
if (
|
||||
separator < 1 ||
|
||||
separator !== field.lastIndexOf('=') ||
|
||||
separator === field.length - 1
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const name = field.slice(0, separator);
|
||||
const value = field.slice(separator + 1);
|
||||
if (values.has(name) || (name !== 'limit' && name !== 'after')) {
|
||||
throw new TypeError();
|
||||
}
|
||||
values.set(name, value);
|
||||
}
|
||||
const rawLimit = values.get('limit');
|
||||
const limit =
|
||||
rawLimit === undefined ? (profile === 'edge' ? 16 : 32) : Number(rawLimit);
|
||||
if (
|
||||
!Number.isSafeInteger(limit) ||
|
||||
limit < 1 ||
|
||||
limit > 64 ||
|
||||
(rawLimit !== undefined && String(limit) !== rawLimit)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const encoded = values.get('after');
|
||||
if (encoded === undefined) return Object.freeze({ limit });
|
||||
if (encoded.length > 256 || !/^[A-Za-z0-9_-]+$/u.test(encoded)) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const bytes = Buffer.from(encoded, 'base64url');
|
||||
let name: string;
|
||||
try {
|
||||
if (bytes.toString('base64url') !== encoded) throw new TypeError();
|
||||
name = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
assertLocalSecretName(name);
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
return Object.freeze({ limit, after: Object.freeze({ name }) });
|
||||
}
|
||||
|
||||
function parseRunAttemptLogReadQuery(
|
||||
rawQuery: string | undefined,
|
||||
profile: LocalApplicationProfile,
|
||||
@@ -534,6 +594,13 @@ function route(
|
||||
: null;
|
||||
}
|
||||
if (request.method === 'PUT') {
|
||||
const secretPutMatch = SECRET_ROUTE_PATTERN.exec(path);
|
||||
if (secretPutMatch && rawQuery === undefined) {
|
||||
return Object.freeze({
|
||||
operationId: 'secret.put',
|
||||
projectId: secretPutMatch[1]!,
|
||||
});
|
||||
}
|
||||
const triggerPutMatch = TRIGGER_READ_ROUTE_PATTERN.exec(path);
|
||||
if (triggerPutMatch && rawQuery === undefined) {
|
||||
return Object.freeze({
|
||||
@@ -552,6 +619,18 @@ function route(
|
||||
: null;
|
||||
}
|
||||
if (request.method !== 'GET') return null;
|
||||
const secretListMatch = SECRET_ROUTE_PATTERN.exec(path);
|
||||
if (secretListMatch) {
|
||||
try {
|
||||
return Object.freeze({
|
||||
operationId: 'secret.list',
|
||||
projectId: secretListMatch[1]!,
|
||||
...parseSecretListQuery(rawQuery, profile),
|
||||
});
|
||||
} catch {
|
||||
return Object.freeze({ errorCode: 'invalid_secret_list_query' });
|
||||
}
|
||||
}
|
||||
const triggerReadMatch = TRIGGER_READ_ROUTE_PATTERN.exec(path);
|
||||
if (triggerReadMatch) {
|
||||
return rawQuery === undefined
|
||||
|
||||
@@ -86,6 +86,51 @@ test('defers Trigger put Policy, presence and mutation to the route', async () =
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses secret.manage for bounded Secret metadata and never widens the route input', async () => {
|
||||
const { admission, events } = fixture();
|
||||
assert.deepEqual(
|
||||
await execute(
|
||||
admission,
|
||||
request({
|
||||
operation: {
|
||||
operationId: 'secret.list',
|
||||
projectId: 'prj_default',
|
||||
limit: 16,
|
||||
after: { name: 'alpha' },
|
||||
},
|
||||
}),
|
||||
),
|
||||
{ statusCode: 200, body: { secrets: [], truncated: false } },
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
'authenticate',
|
||||
'authorize:secret.manage:prj_default',
|
||||
'audit:allowed:secret.list',
|
||||
'confirm',
|
||||
'secrets:prj_default:16',
|
||||
]);
|
||||
});
|
||||
|
||||
test('defers Secret put Policy, presence and plaintext body to the fenced route', async () => {
|
||||
const { admission, events } = fixture();
|
||||
const prepared = await admission.prepare(
|
||||
request({
|
||||
localPresence: 'ql3p_bound',
|
||||
operation: {
|
||||
operationId: 'secret.put',
|
||||
projectId: 'prj_default',
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(prepared.bodyMode, 'json');
|
||||
assert.equal(prepared.maximumBodyBytes, 20 * 1024);
|
||||
assert.deepEqual(await prepared.handle({ plaintext: 'ephemeral' }), {
|
||||
statusCode: 201,
|
||||
body: { status: 'inserted' },
|
||||
});
|
||||
assert.deepEqual(events, ['authenticate', 'secret-put:prj_default']);
|
||||
});
|
||||
|
||||
function request(overrides = {}) {
|
||||
return Object.freeze({
|
||||
requestId: 'local:019f70c0-0000-7000-8000-000000000001',
|
||||
@@ -246,6 +291,18 @@ function fixture(overrides = {}) {
|
||||
return { statusCode: 201, body: { status: 'created' } };
|
||||
},
|
||||
},
|
||||
secretListRoute: {
|
||||
async handle(value) {
|
||||
events.push(`secrets:${value.projectId}:${value.limit}`);
|
||||
return { statusCode: 200, body: { secrets: [], truncated: false } };
|
||||
},
|
||||
},
|
||||
secretPutRoute: {
|
||||
async handle(value) {
|
||||
events.push(`secret-put:${value.projectId}`);
|
||||
return { statusCode: 201, body: { status: 'inserted' } };
|
||||
},
|
||||
},
|
||||
now: () => 10_000,
|
||||
randomUuid: () => '019f70c0-0000-4000-8000-000000000002',
|
||||
...overrides,
|
||||
|
||||
@@ -90,6 +90,15 @@ test('loads one bounded offline Console asset closure', () => {
|
||||
assert.match(text, /triggers\/\$\{mutation\.triggerId\}/u);
|
||||
assert.match(text, /state\.view === 'triggers'/u);
|
||||
assert.match(text, /trigger_fence_rejected/u);
|
||||
assert.match(text, /state\.view === 'secrets'/u);
|
||||
assert.match(text, /secret-mutation/u);
|
||||
assert.match(text, /createSecretRef/u);
|
||||
assert.match(text, /kind: 'secret'/u);
|
||||
assert.match(text, /secret_query_unavailable/u);
|
||||
assert.equal(
|
||||
/localStorage.*plaintext|sessionStorage.*plaintext/u.test(text),
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (requestPath === '/') {
|
||||
assert.match(text, /id="task-editor-dialog"/u);
|
||||
@@ -99,6 +108,10 @@ test('loads one bounded offline Console asset closure', () => {
|
||||
assert.match(text, /id="presence-copy"/u);
|
||||
assert.match(text, /id="trigger-editor-dialog"/u);
|
||||
assert.match(text, /data-view="triggers"/u);
|
||||
assert.match(text, /data-view="secrets"/u);
|
||||
assert.match(text, /id="secret-editor-dialog"/u);
|
||||
assert.match(text, /id="task-secret-bindings-input"/u);
|
||||
assert.match(text, /AES-256-GCM/u);
|
||||
}
|
||||
}
|
||||
assert.ok(totalBytes <= 192 * 1024);
|
||||
|
||||
@@ -52,14 +52,19 @@ function request(port, path, options = {}) {
|
||||
function preparedAdmission(handler) {
|
||||
return {
|
||||
async prepare(value) {
|
||||
const json = ['run.cancel', 'task.start', 'task.put'].includes(
|
||||
value.operation.operationId,
|
||||
);
|
||||
const json = [
|
||||
'run.cancel',
|
||||
'task.start',
|
||||
'task.put',
|
||||
'secret.put',
|
||||
].includes(value.operation.operationId);
|
||||
return {
|
||||
bodyMode: json ? 'json' : 'none',
|
||||
maximumBodyBytes:
|
||||
value.operation.operationId === 'task.put'
|
||||
? 72 * 1024
|
||||
: value.operation.operationId === 'secret.put'
|
||||
? 20 * 1024
|
||||
: json
|
||||
? 512
|
||||
: 0,
|
||||
@@ -83,7 +88,8 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
if (
|
||||
value.operation.operationId === 'run.cancel' ||
|
||||
value.operation.operationId === 'task.start' ||
|
||||
value.operation.operationId === 'task.put'
|
||||
value.operation.operationId === 'task.put' ||
|
||||
value.operation.operationId === 'secret.put'
|
||||
) {
|
||||
return { statusCode: 202, body: { accepted: body } };
|
||||
}
|
||||
@@ -140,6 +146,12 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
body: { task: { taskId: value.operation.taskId } },
|
||||
};
|
||||
}
|
||||
if (value.operation.operationId === 'secret.list') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { secrets: [], truncated: false },
|
||||
};
|
||||
}
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { runs: [], hasMore: false, input: value.operation.input },
|
||||
@@ -356,6 +368,41 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
});
|
||||
assert.equal(observed[11].localPresence, 'ql3p_authoring_read_proof');
|
||||
|
||||
const secrets = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/secrets?limit=8&after=YWxwaGE',
|
||||
);
|
||||
assert.deepEqual(secrets.body, { secrets: [], truncated: false });
|
||||
assert.deepEqual(observed[12].operation, {
|
||||
operationId: 'secret.list',
|
||||
projectId: 'prj_default',
|
||||
limit: 8,
|
||||
after: { name: 'alpha' },
|
||||
});
|
||||
|
||||
const secretPutBody = JSON.stringify({ name: 'github-token' });
|
||||
const secretPut = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/secrets',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
authorization: 'Bearer opaque',
|
||||
'x-qinglong-local-presence': 'ql3p_secret_bound_proof',
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(Buffer.byteLength(secretPutBody)),
|
||||
},
|
||||
body: secretPutBody,
|
||||
},
|
||||
);
|
||||
assert.equal(secretPut.statusCode, 202);
|
||||
assert.deepEqual(secretPut.body.accepted, JSON.parse(secretPutBody));
|
||||
assert.deepEqual(observed[13].operation, {
|
||||
operationId: 'secret.put',
|
||||
projectId: 'prj_default',
|
||||
});
|
||||
assert.equal(observed[13].localPresence, 'ql3p_secret_bound_proof');
|
||||
|
||||
for (const invalidPath of [
|
||||
'/api/v3/projects/prj_default/runs/run_123?expanded=true',
|
||||
'/api/v3/projects/prj_default/runs/run%5f123',
|
||||
@@ -402,6 +449,17 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
assert.deepEqual(invalid.body, { code: 'invalid_task_list_query' });
|
||||
}
|
||||
for (const invalidQuery of [
|
||||
'/api/v3/projects/prj_default/secrets?',
|
||||
'/api/v3/projects/prj_default/secrets?limit=08',
|
||||
'/api/v3/projects/prj_default/secrets?limit=65',
|
||||
'/api/v3/projects/prj_default/secrets?after=Y',
|
||||
'/api/v3/projects/prj_default/secrets?unknown=value',
|
||||
]) {
|
||||
const invalid = await request(port, invalidQuery);
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
assert.deepEqual(invalid.body, { code: 'invalid_secret_list_query' });
|
||||
}
|
||||
for (const invalidQuery of [
|
||||
'/api/v3/projects/prj_default/runs/run_123/events?',
|
||||
'/api/v3/projects/prj_default/runs/run_123/events?after_sequence=07',
|
||||
@@ -425,7 +483,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
assert.deepEqual(invalid.body, { code: 'invalid_run_step_list_query' });
|
||||
}
|
||||
assert.equal(observed.length, 12);
|
||||
assert.equal(observed.length, 14);
|
||||
assert.deepEqual(
|
||||
await Promise.all([surface.stopAndDrain(), surface.stopAndDrain()]),
|
||||
['stopped', 'stopped'],
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createLocalPresenceProofManager,
|
||||
} = require('../dist/authentication/localPresenceProof.js');
|
||||
const {
|
||||
createLocalApiSecretListRoute,
|
||||
createLocalApiSecretPutRoute,
|
||||
} = require('../dist/secret/secretRoutes.js');
|
||||
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'owner' }),
|
||||
authenticationId: 'local_credential:owner-console:1',
|
||||
authenticatedAtMs: 9_000,
|
||||
expiresAtMs: 60_000,
|
||||
assurance: 'single_factor',
|
||||
});
|
||||
|
||||
const FENCE = Object.freeze({
|
||||
credentialId: 'owner-console',
|
||||
credentialVersion: 1,
|
||||
pepperKeyId: 'owner-v1',
|
||||
materialDigest: 'a'.repeat(64),
|
||||
subjectType: 'user',
|
||||
subjectId: 'owner',
|
||||
secretDigest: 'b'.repeat(64),
|
||||
notBeforeAtMs: 1,
|
||||
expiresAtMs: 60_000,
|
||||
});
|
||||
|
||||
function uuidFactory() {
|
||||
let sequence = 400;
|
||||
return () => {
|
||||
sequence += 1;
|
||||
return `019f9200-0000-4000-8000-${String(sequence).padStart(12, '0')}`;
|
||||
};
|
||||
}
|
||||
|
||||
function body(overrides = {}) {
|
||||
return Object.freeze({
|
||||
name: 'github-token',
|
||||
plaintext: 'never-return-this-value',
|
||||
mutationId: '019f9200-0000-4000-8000-000000000101',
|
||||
expectedCurrentVersion: 0,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
const deploymentRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-secret-put-'),
|
||||
);
|
||||
fs.chmodSync(deploymentRoot, 0o700);
|
||||
t.after(() => fs.rmSync(deploymentRoot, { recursive: true, force: true }));
|
||||
const calls = [];
|
||||
const presenceProof = createLocalPresenceProofManager({
|
||||
deploymentRoot,
|
||||
profile: 'edge',
|
||||
now: () => 10_000,
|
||||
randomUuid: uuidFactory(),
|
||||
randomSecret: () => Buffer.alloc(32, 17),
|
||||
});
|
||||
t.after(() => presenceProof.close());
|
||||
const projectPolicy = {
|
||||
async resolve(projectId, subject) {
|
||||
calls.push(['policy', projectId, subject]);
|
||||
return {
|
||||
project: {
|
||||
id: projectId,
|
||||
name: 'Default',
|
||||
slug: 'default',
|
||||
status: 'active',
|
||||
version: 3,
|
||||
createdAtMs: 1,
|
||||
updatedAtMs: 2,
|
||||
},
|
||||
binding: {
|
||||
projectId,
|
||||
subject,
|
||||
version: 5,
|
||||
state: 'active',
|
||||
role: 'owner',
|
||||
mutationId: 'owner-binding',
|
||||
changedBy: { type: 'user', id: 'bootstrap-owner' },
|
||||
createdAtMs: 2,
|
||||
},
|
||||
};
|
||||
},
|
||||
async append() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
const route = createLocalApiSecretPutRoute({
|
||||
projectPolicy,
|
||||
async secretAdministrationForCredential(fence) {
|
||||
calls.push(['credential-fence', fence]);
|
||||
return {
|
||||
async resolveLocalSecretAdministrationMutation() {
|
||||
return null;
|
||||
},
|
||||
async appendAuthorizedLocalSecretEnvelope(command) {
|
||||
calls.push(['mutation', command]);
|
||||
return {
|
||||
status: 'inserted',
|
||||
envelope: command.envelope,
|
||||
audit: command.audit,
|
||||
};
|
||||
},
|
||||
async record() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
},
|
||||
securityAudit: {
|
||||
async record(record) {
|
||||
calls.push(['audit', record]);
|
||||
},
|
||||
},
|
||||
secretKeys: {
|
||||
async active() {
|
||||
calls.push(['active-key']);
|
||||
return { keyId: 'active-key', key: Buffer.alloc(32, 23) };
|
||||
},
|
||||
async resolve() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
presenceProof,
|
||||
now: () => 10_000,
|
||||
randomUuid: uuidFactory(),
|
||||
});
|
||||
const authenticated = Object.freeze({
|
||||
principal: PRINCIPAL,
|
||||
credentialFence: FENCE,
|
||||
async confirm() {
|
||||
calls.push(['confirm']);
|
||||
},
|
||||
});
|
||||
return { route, calls, deploymentRoot, authenticated };
|
||||
}
|
||||
|
||||
function request(state, requestBody, overrides = {}) {
|
||||
return Object.freeze({
|
||||
requestId: 'local:019f9200-0000-4000-8000-000000000301',
|
||||
projectId: 'default',
|
||||
body: requestBody,
|
||||
presence: null,
|
||||
authenticated: state.authenticated,
|
||||
signal: new AbortController().signal,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function readProof(state, challenge) {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
state.deploymentRoot,
|
||||
'console-presence',
|
||||
challenge.body.proofFileName,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
).proof;
|
||||
}
|
||||
|
||||
test('lists bounded Secret metadata without storage or mutation material', async () => {
|
||||
const route = createLocalApiSecretListRoute({
|
||||
async listLocalSecretMetadata(options) {
|
||||
assert.deepEqual(options, { projectId: 'default', limit: 1 });
|
||||
return {
|
||||
secrets: [
|
||||
{
|
||||
projectId: 'default',
|
||||
name: 'github-token',
|
||||
currentVersion: 2,
|
||||
createdAtMs: 10_000,
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
next: { name: 'github-token' },
|
||||
};
|
||||
},
|
||||
});
|
||||
const result = await route.handle({ projectId: 'default', limit: 1 });
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.deepEqual(Object.keys(result.body.secrets[0]).sort(), [
|
||||
'createdAtMs',
|
||||
'currentVersion',
|
||||
'name',
|
||||
'secretRef',
|
||||
]);
|
||||
assert.equal(
|
||||
result.body.secrets[0].secretRef.startsWith('qlsecret:v1:'),
|
||||
true,
|
||||
);
|
||||
assert.equal(result.body.next.after, 'Z2l0aHViLXRva2Vu');
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(result.body),
|
||||
/cipher|plaintext|mutation|keyId/u,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed on widened, cross-Project and over-budget metadata', async () => {
|
||||
for (const page of [
|
||||
{
|
||||
secrets: [
|
||||
{
|
||||
projectId: 'other',
|
||||
name: 'github-token',
|
||||
currentVersion: 2,
|
||||
createdAtMs: 10_000,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
},
|
||||
{
|
||||
secrets: [
|
||||
{
|
||||
projectId: 'default',
|
||||
name: 'github-token',
|
||||
currentVersion: 2,
|
||||
createdAtMs: 10_000,
|
||||
ciphertext: 'forbidden',
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
},
|
||||
{
|
||||
secrets: [
|
||||
{
|
||||
projectId: 'default',
|
||||
name: 'first',
|
||||
currentVersion: 1,
|
||||
createdAtMs: 10_000,
|
||||
},
|
||||
{
|
||||
projectId: 'default',
|
||||
name: 'second',
|
||||
currentVersion: 1,
|
||||
createdAtMs: 10_001,
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
next: { name: 'second' },
|
||||
},
|
||||
]) {
|
||||
const route = createLocalApiSecretListRoute({
|
||||
async listLocalSecretMetadata() {
|
||||
return page;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await route.handle({ projectId: 'default', limit: 1 }), {
|
||||
statusCode: 503,
|
||||
body: { code: 'secret_query_unavailable' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('requires exact local presence and returns no Secret plaintext', async (t) => {
|
||||
const state = fixture(t);
|
||||
const command = body();
|
||||
const challenge = await state.route.handle(request(state, command));
|
||||
assert.equal(challenge.statusCode, 428);
|
||||
const result = await state.route.handle(
|
||||
request(state, command, { presence: readProof(state, challenge) }),
|
||||
);
|
||||
assert.equal(result.statusCode, 201);
|
||||
assert.deepEqual(result.body, {
|
||||
status: 'inserted',
|
||||
secret: {
|
||||
name: 'github-token',
|
||||
currentVersion: 1,
|
||||
secretRef: result.body.secret.secretRef,
|
||||
},
|
||||
});
|
||||
assert.equal(JSON.stringify(result).includes(command.plaintext), false);
|
||||
const mutation = state.calls.find(([kind]) => kind === 'mutation')[1];
|
||||
assert.equal(
|
||||
Buffer.from(mutation.envelope.ciphertext, 'base64url').includes(
|
||||
Buffer.from(command.plaintext),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(
|
||||
state.calls
|
||||
.filter(([kind]) => kind === 'audit')
|
||||
.map(([, audit]) => [audit.operationId, audit.outcome, audit.reasons[0]]),
|
||||
[['secret.create', 'approval_required', 'local_presence_required']],
|
||||
);
|
||||
});
|
||||
|
||||
test('binds proof to exact plaintext digest and rejects widened bodies', async (t) => {
|
||||
const state = fixture(t);
|
||||
assert.deepEqual(
|
||||
await state.route.handle(request(state, { ...body(), extra: true })),
|
||||
{ statusCode: 400, body: { code: 'invalid_secret' } },
|
||||
);
|
||||
const command = body();
|
||||
const challenge = await state.route.handle(request(state, command));
|
||||
const proof = readProof(state, challenge);
|
||||
assert.deepEqual(
|
||||
await state.route.handle(
|
||||
request(state, body({ plaintext: 'changed-value' }), { presence: proof }),
|
||||
),
|
||||
{ statusCode: 401, body: { code: 'local_presence_rejected' } },
|
||||
);
|
||||
assert.equal(state.calls.filter(([kind]) => kind === 'mutation').length, 0);
|
||||
});
|
||||
@@ -187,8 +187,8 @@ function seed(databasePath, materialDigest) {
|
||||
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||
"created_at_ms"
|
||||
) VALUES (
|
||||
'default', 'user', 'local-api-user', 1, 'active', 'operator',
|
||||
'grant-local-api-operator', 'user', 'local-api-user', ?
|
||||
'default', 'user', 'local-api-user', 1, 'active', 'owner',
|
||||
'grant-local-api-owner', 'user', 'local-api-user', ?
|
||||
)`,
|
||||
)
|
||||
.run(NOW - 500);
|
||||
@@ -442,6 +442,19 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
triggers: runtime.triggers,
|
||||
triggerAdministrationForCredential:
|
||||
runtime.triggerAdministrationForCredential,
|
||||
localSecretMetadata: runtime.localSecretMetadata,
|
||||
localSecretAdministrationForCredential:
|
||||
runtime.localSecretAdministrationForCredential,
|
||||
localSecretKeys: {
|
||||
async active() {
|
||||
return { keyId: 'integration-key', key: Buffer.alloc(32, 83) };
|
||||
},
|
||||
async resolve(keyId) {
|
||||
return keyId === 'integration-key'
|
||||
? { keyId, key: Buffer.alloc(32, 83) }
|
||||
: null;
|
||||
},
|
||||
},
|
||||
apiCredentials: runtime.apiCredentials,
|
||||
ownerPepper: runtime.ownerPepper,
|
||||
projectPolicy: runtime.projectPolicy,
|
||||
@@ -531,6 +544,63 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
{ statusCode: 404, body: { code: 'task_not_found' } },
|
||||
);
|
||||
|
||||
const secretPlaintext = 'local-api-secret-value';
|
||||
const secretBody = JSON.stringify({
|
||||
name: 'github-token',
|
||||
plaintext: secretPlaintext,
|
||||
mutationId: '019f7300-0000-4000-8000-000000000700',
|
||||
expectedCurrentVersion: 0,
|
||||
});
|
||||
const secretPath = '/api/v3/projects/default/secrets';
|
||||
const secretOptions = {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(Buffer.byteLength(secretBody)),
|
||||
},
|
||||
body: secretBody,
|
||||
};
|
||||
const secretChallenge = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
secretPath,
|
||||
secretOptions,
|
||||
);
|
||||
assert.equal(secretChallenge.statusCode, 428);
|
||||
assert.equal(secretChallenge.body.code, 'local_presence_required');
|
||||
const secretProof = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(root, 'console-presence', secretChallenge.body.proofFileName),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const secretCreated = await request(port, `Bearer ${TOKEN}`, secretPath, {
|
||||
...secretOptions,
|
||||
headers: {
|
||||
...secretOptions.headers,
|
||||
'x-qinglong-local-presence': secretProof.proof,
|
||||
},
|
||||
});
|
||||
assert.equal(secretCreated.statusCode, 201);
|
||||
assert.equal(secretCreated.body.secret.currentVersion, 1);
|
||||
assert.match(secretCreated.body.secret.secretRef, /^qlsecret:v1:/u);
|
||||
assert.equal(JSON.stringify(secretCreated).includes(secretPlaintext), false);
|
||||
|
||||
const secretList = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
`${secretPath}?limit=64`,
|
||||
);
|
||||
assert.equal(secretList.statusCode, 200);
|
||||
assert.deepEqual(secretList.body.secrets, [
|
||||
{ ...secretCreated.body.secret, createdAtMs: NOW },
|
||||
]);
|
||||
assert.equal(secretList.body.truncated, false);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(secretList),
|
||||
new RegExp(`${secretPlaintext}|ciphertext|keyId|mutationId`, 'u'),
|
||||
);
|
||||
|
||||
const taskCreateBody = JSON.stringify({
|
||||
expectedRevision: null,
|
||||
mutationId: '019f7300-0000-4000-8000-000000000701',
|
||||
@@ -545,6 +615,13 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
file: '/bin/echo',
|
||||
args: ['console-created'],
|
||||
},
|
||||
environment: [
|
||||
{
|
||||
name: 'API_TOKEN',
|
||||
kind: 'secret',
|
||||
secretRef: secretCreated.body.secret.secretRef,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
labels: { source: 'local-console' },
|
||||
@@ -973,6 +1050,40 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
);
|
||||
const auditReader = new DatabaseSync(databasePath, { readOnly: true });
|
||||
try {
|
||||
const encryptedSecret = auditReader
|
||||
.prepare(
|
||||
`SELECT ciphertext FROM "QingLong3LocalSecretEnvelopes"
|
||||
WHERE project_id = 'default' AND secret_name = 'github-token'
|
||||
AND version = 1`,
|
||||
)
|
||||
.get();
|
||||
assert.equal(
|
||||
Buffer.from(encryptedSecret.ciphertext).includes(
|
||||
Buffer.from(secretPlaintext),
|
||||
),
|
||||
false,
|
||||
);
|
||||
const createdSpec = JSON.parse(
|
||||
auditReader
|
||||
.prepare(
|
||||
`SELECT revision.spec_json AS specJson
|
||||
FROM "QingLong3TaskDefinitions" AS head
|
||||
JOIN "QingLong3TaskDefinitionRevisions" AS revision
|
||||
ON revision.project_id = head.project_id
|
||||
AND revision.task_id = head.task_id
|
||||
AND revision.revision = head.current_revision
|
||||
WHERE head.project_id = 'default'
|
||||
AND head.task_id = 'task-console-created'`,
|
||||
)
|
||||
.get().specJson,
|
||||
);
|
||||
assert.deepEqual(createdSpec.config.environment, [
|
||||
{
|
||||
name: 'API_TOKEN',
|
||||
kind: 'secret',
|
||||
secretRef: secretCreated.body.secret.secretRef,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
auditReader
|
||||
.prepare(
|
||||
@@ -981,7 +1092,8 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
'run.get', 'run.list', 'run.events.list', 'run.steps.list',
|
||||
'run.cancel', 'task.authoring.read', 'task.create', 'task.get',
|
||||
'task.list', 'task.start', 'task.update', 'run.log.read',
|
||||
'trigger.create', 'trigger.get', 'trigger.list', 'trigger.update'
|
||||
'trigger.create', 'trigger.get', 'trigger.list', 'trigger.update',
|
||||
'secret.create', 'secret.list'
|
||||
)
|
||||
ORDER BY operation_id, outcome`,
|
||||
)
|
||||
@@ -996,6 +1108,9 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
'run.list:allowed',
|
||||
'run.log.read:allowed',
|
||||
'run.steps.list:allowed',
|
||||
'secret.create:allowed',
|
||||
'secret.create:approval_required',
|
||||
'secret.list:allowed',
|
||||
'task.authoring.read:allowed',
|
||||
'task.authoring.read:approval_required',
|
||||
'task.create:allowed',
|
||||
|
||||
Reference in New Issue
Block a user