feat(ql3): add secret-backed console automation

This commit is contained in:
whyour
2026-08-29 19:44:04 +08:00
parent 53d65abd8d
commit f46fb44ac9
26 changed files with 2272 additions and 39 deletions
@@ -25,8 +25,11 @@ type TriggerAdministrationForCredential =
type LocalScheduleStore = ReadyLocalStorage['schedules'];
type LocalDispatchStore = ReadyLocalStorage['dispatch'];
type LocalSecretEnvelopeRepository = ReadyLocalStorage['localSecrets'];
type LocalSecretMetadataSource = ReadyLocalStorage['localSecretMetadata'];
type LocalSecretAdministrationRepository =
ReadyLocalStorage['localSecretAdministration'];
type LocalSecretAdministrationForCredential =
ReadyLocalStorage['localSecretAdministrationForCredential'];
type ProjectPolicyRepository = ReadyLocalStorage['projectPolicy'];
type SecurityAuditSink = ReadyLocalStorage['securityAudit'];
type ApiCredentialRepository = ReadyLocalStorage['apiCredentials'];
@@ -102,7 +105,9 @@ export type LocalAdoptedProfileBootstrapResult =
readonly runAttemptLogRetention: ReadyLocalStorage['runAttemptLogRetention'];
readonly runLostRetry: ReadyLocalStorage['runLostRetry'];
readonly localSecrets: LocalSecretEnvelopeRepository;
readonly localSecretMetadata: LocalSecretMetadataSource;
readonly localSecretAdministration: LocalSecretAdministrationRepository;
readonly localSecretAdministrationForCredential: LocalSecretAdministrationForCredential;
readonly projectPolicy: ProjectPolicyRepository;
readonly securityAudit: SecurityAuditSink;
readonly apiCredentials: ApiCredentialRepository;
@@ -255,7 +260,10 @@ export async function bootstrapLocalAdoptedProfileStorage(
runAttemptLogRetention: readyStorage.runAttemptLogRetention,
runLostRetry: readyStorage.runLostRetry,
localSecrets: readyStorage.localSecrets,
localSecretMetadata: readyStorage.localSecretMetadata,
localSecretAdministration: readyStorage.localSecretAdministration,
localSecretAdministrationForCredential:
readyStorage.localSecretAdministrationForCredential,
projectPolicy: readyStorage.projectPolicy,
securityAudit: readyStorage.securityAudit,
apiCredentials: readyStorage.apiCredentials,
@@ -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 新值必须为 116384 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',
@@ -525,6 +525,10 @@ export async function bootstrapLocalApplication(
triggers: storage.triggers,
triggerAdministrationForCredential:
storage.triggerAdministrationForCredential,
localSecretMetadata: storage.localSecretMetadata,
localSecretAdministrationForCredential:
storage.localSecretAdministrationForCredential,
localSecretKeys: secretKeys,
apiCredentials: storage.apiCredentials,
ownerPepper: storage.ownerPepper,
projectPolicy: storage.projectPolicy,
@@ -22,6 +22,7 @@ import type {
RunAttemptLogReadRequest,
RunAttemptLogReadResult,
} from '@qinglong/runtime-core/run-attempt-log-read';
import type { LocalSecretKeyProvider } from '@qinglong/runtime-core/local-secret';
export type LocalApplicationProfile = 'edge' | 'standalone';
@@ -71,6 +72,9 @@ export interface LocalApplicationProductSurfaceAuthority {
readonly taskDefinitionAdministrationForCredential: ReadyFreshStorage['taskDefinitionAdministrationForCredential'];
readonly triggers: ReadyFreshStorage['triggers'];
readonly triggerAdministrationForCredential: ReadyFreshStorage['triggerAdministrationForCredential'];
readonly localSecretMetadata: ReadyFreshStorage['localSecretMetadata'];
readonly localSecretAdministrationForCredential: ReadyFreshStorage['localSecretAdministrationForCredential'];
readonly localSecretKeys: LocalSecretKeyProvider;
readonly runAttemptLogRead: Readonly<{
read(
request: Readonly<RunAttemptLogReadRequest>,
@@ -52,7 +52,9 @@ export type LocalProfileStorageBootstrapResult =
readonly runAttemptLogRetention: LocalSqliteRuntimeDatabase['runAttemptLogRetention'];
readonly runLostRetry: LocalSqliteRuntimeDatabase['runLostRetry'];
readonly localSecrets: LocalSqliteRuntimeDatabase['localSecrets'];
readonly localSecretMetadata: LocalSqliteRuntimeDatabase['localSecretMetadata'];
readonly localSecretAdministration: LocalSqliteRuntimeDatabase['localSecretAdministration'];
readonly localSecretAdministrationForCredential: LocalSqliteRuntimeDatabase['localSecretAdministrationForCredential'];
readonly projectPolicy: LocalSqliteRuntimeDatabase['projectPolicy'];
readonly securityAudit: LocalSqliteRuntimeDatabase['securityAudit'];
readonly apiCredentials: LocalSqliteRuntimeDatabase['apiCredentials'];
@@ -153,7 +155,10 @@ export async function bootstrapLocalProfileStorage(
runAttemptLogRetention: database.runAttemptLogRetention,
runLostRetry: database.runLostRetry,
localSecrets: database.localSecrets,
localSecretMetadata: database.localSecretMetadata,
localSecretAdministration: database.localSecretAdministration,
localSecretAdministrationForCredential:
database.localSecretAdministrationForCredential,
projectPolicy: database.projectPolicy,
securityAudit: database.securityAudit,
apiCredentials: database.apiCredentials,
@@ -17,7 +17,10 @@ import type { LocalRunStartupRecoverySource } from '@qinglong/runtime-core/local
import type { LocalDispatchStore } from '@qinglong/runtime-core/local-dispatch';
import type { LocalExecutionControlSource } from '@qinglong/runtime-core/local-execution-control';
import type { LocalCompletionReceiptJournal } from '@qinglong/runtime-core/local-completion-receipt-journal';
import type { LocalSecretEnvelopeRepository } from '@qinglong/runtime-core/local-secret';
import type {
LocalSecretEnvelopeRepository,
LocalSecretMetadataSource,
} from '@qinglong/runtime-core/local-secret';
import type { LocalSecretAdministrationRepository } from '@qinglong/runtime-core/local-secret-administration';
import type { ProjectPolicyRepository } from '@qinglong/runtime-core/project-policy';
import type { SecurityAuditSink } from '@qinglong/runtime-core/security-audit';
@@ -49,6 +52,7 @@ import type {
ProjectToolDefinitionSnapshotSourceRepository,
} from '@qinglong/runtime-core/project-tool-definition-snapshot';
import { LocalSqliteApiCredentialRepository } from '../security/apiCredentialRepository';
import { LocalSqliteSecretMetadataRepository } from '../security/secretMetadataRepository';
import { LocalSqliteOwnerPepperRepository } from '../local-owner/ownerPepperRepository';
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
import { LocalSqliteTaskDefinitionRepository } from '../task-definition/taskDefinitionRepository';
@@ -113,7 +117,11 @@ export interface LocalSqliteRuntimeDatabase {
readonly runAttemptLogRetention: LocalSqliteRunAttemptLogRetentionRepository;
readonly runLostRetry: LocalSqliteRunLostRetryRepository;
readonly localSecrets: LocalSecretEnvelopeRepository;
readonly localSecretMetadata: LocalSecretMetadataSource;
readonly localSecretAdministration: LocalSecretAdministrationRepository;
localSecretAdministrationForCredential(
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
): Promise<LocalSecretAdministrationRepository>;
readonly projectPolicy: ProjectPolicyRepository;
readonly securityAudit: SecurityAuditSink;
readonly apiCredentials: ApiCredentialRepository;
@@ -184,6 +192,9 @@ export async function openLocalSqliteRuntimeDatabase(
const runRuntimeCapabilities =
createLocalSqliteRunRuntimeCapabilities(authority);
const securityAuthority = new LocalSqliteSecurityAuthorityStore(authority);
const localSecretMetadata = new LocalSqliteSecretMetadataRepository(
authority,
);
const taskDefinitions = new LocalSqliteTaskDefinitionRepository(
authority,
taskSpecSemanticRegistry,
@@ -326,7 +337,23 @@ export async function openLocalSqliteRuntimeDatabase(
runAttemptLogRetention,
runLostRetry,
localSecrets: securityAuthority,
localSecretMetadata,
localSecretAdministration: securityAuthority,
async localSecretAdministrationForCredential(
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
) {
const { confirmLocalSqliteAuthenticatedUserCredentialFence } =
await import('../administration/packageManagement.js');
confirmLocalSqliteAuthenticatedUserCredentialFence(authority, fence);
return new LocalSqliteSecurityAuthorityStore(authority, {
beforeAuthorizedLocalSecretMutation() {
confirmLocalSqliteAuthenticatedUserCredentialFence(
authority,
fence,
);
},
});
},
projectPolicy,
securityAudit: securityAuthority,
apiCredentials,
@@ -0,0 +1,133 @@
import {
LocalSecretMetadataUnavailableError,
MAX_LOCAL_SECRET_BATCH_SIZE,
assertLocalSecretName,
assertLocalSecretProjectId,
assertLocalSecretVersion,
type LocalSecretMetadata,
type LocalSecretMetadataPage,
type LocalSecretMetadataSource,
} from '@qinglong/runtime-core/local-secret';
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
type Row = Record<string, unknown>;
function integer(row: Row, key: string): number {
const value = row[key];
if (!Number.isSafeInteger(value)) {
throw new LocalSecretMetadataUnavailableError();
}
return value as number;
}
function text(row: Row, key: string): string {
const value = row[key];
if (typeof value !== 'string') {
throw new LocalSecretMetadataUnavailableError();
}
return value;
}
function metadata(row: Row): Readonly<LocalSecretMetadata> {
try {
const projectId = text(row, 'projectId');
const name = text(row, 'name');
const currentVersion = integer(row, 'currentVersion');
const createdAtMs = integer(row, 'createdAtMs');
assertLocalSecretProjectId(projectId);
assertLocalSecretName(name);
assertLocalSecretVersion(currentVersion);
if (createdAtMs < 0) throw new Error('invalid Secret timestamp');
return Object.freeze({ projectId, name, currentVersion, createdAtMs });
} catch (error) {
if (error instanceof LocalSecretMetadataUnavailableError) throw error;
throw new LocalSecretMetadataUnavailableError();
}
}
export class LocalSqliteSecretMetadataRepository
implements LocalSecretMetadataSource
{
constructor(private readonly authority: LocalSqliteOperationAuthority) {
if (!(authority instanceof LocalSqliteOperationAuthority)) {
throw new TypeError('Local Secret metadata authority is invalid');
}
}
listLocalSecretMetadata(options: {
readonly projectId: string;
readonly limit: number;
readonly after?: Readonly<{ readonly name: string }>;
}): Promise<Readonly<LocalSecretMetadataPage>> {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
Object.keys(options).some(
(key) => !['after', 'limit', 'projectId'].includes(key),
) ||
!Object.hasOwn(options, 'limit') ||
!Object.hasOwn(options, 'projectId') ||
!Number.isSafeInteger(options.limit) ||
options.limit < 1 ||
options.limit > MAX_LOCAL_SECRET_BATCH_SIZE ||
(options.after !== undefined &&
(!options.after ||
typeof options.after !== 'object' ||
Array.isArray(options.after) ||
Object.keys(options.after).join('') !== 'name'))
) {
throw new TypeError('Local Secret metadata list options are invalid');
}
assertLocalSecretProjectId(options.projectId);
if (options.after) assertLocalSecretName(options.after.name);
return this.authority.enqueue(
async () => {
try {
const rows = this.authority.client
.prepare(
`SELECT secret."project_id" AS "projectId",
secret."secret_name" AS "name",
secret."version" AS "currentVersion",
secret."created_at_ms" AS "createdAtMs"
FROM "QingLong3LocalSecretEnvelopes" AS secret
WHERE secret."project_id" = ?
AND secret."secret_name" > ?
AND secret."version" = (
SELECT MAX(current."version")
FROM "QingLong3LocalSecretEnvelopes" AS current
WHERE current."project_id" = secret."project_id"
AND current."secret_name" = secret."secret_name"
)
ORDER BY secret."secret_name"
LIMIT ?`,
)
.all(
options.projectId,
options.after?.name ?? '',
options.limit + 1,
) as Row[] | undefined;
if (!Array.isArray(rows)) {
throw new LocalSecretMetadataUnavailableError();
}
const truncated = rows.length > options.limit;
const secrets = Object.freeze(
rows.slice(0, options.limit).map(metadata),
);
const last = secrets.at(-1);
return Object.freeze({
secrets,
truncated,
...(truncated && last
? { next: Object.freeze({ name: last.name }) }
: {}),
});
} catch {
throw new LocalSecretMetadataUnavailableError();
}
},
() => new LocalSecretMetadataUnavailableError(),
);
}
}
@@ -0,0 +1,115 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { DatabaseSync } = require('node:sqlite');
const { test } = require('node:test');
const { migrateLocalSqlitePath } = require('../dist/migration/migration.js');
const {
openLocalSqliteRuntimeDatabase,
} = require('../dist/runtime/runtimeDatabase.js');
function insertSecret(database, name, version, createdAtMs) {
database
.prepare(
`INSERT INTO "QingLong3LocalSecretEnvelopes"
("project_id", "secret_name", "version", "mutation_id", "key_id",
"algorithm", "nonce", "ciphertext", "auth_tag", "created_at_ms")
VALUES ('default', ?, ?, ?, 'active-key', 'aes-256-gcm', ?, ?, ?, ?)`,
)
.run(
name,
version,
`00000000-0000-4000-8000-${String(createdAtMs).padStart(12, '0')}`,
Buffer.alloc(12, version),
Buffer.from(`cipher-${name}-${version}`),
Buffer.alloc(16, version),
createdAtMs,
);
}
test('lists only current Secret metadata with a stable bounded cursor', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-secret-meta-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const options = {
directory,
profile: 'edge',
databasePath: path.join(directory, 'qinglong3.sqlite'),
};
await migrateLocalSqlitePath(options);
const database = new DatabaseSync(options.databasePath);
insertSecret(database, 'alpha', 1, 101);
insertSecret(database, 'alpha', 2, 102);
insertSecret(database, 'beta', 1, 103);
insertSecret(database, 'gamma', 1, 104);
database.close();
const runtime = await openLocalSqliteRuntimeDatabase(options);
const first = await runtime.localSecretMetadata.listLocalSecretMetadata({
projectId: 'default',
limit: 2,
});
assert.deepEqual(first, {
secrets: [
{
projectId: 'default',
name: 'alpha',
currentVersion: 2,
createdAtMs: 102,
},
{
projectId: 'default',
name: 'beta',
currentVersion: 1,
createdAtMs: 103,
},
],
truncated: true,
next: { name: 'beta' },
});
assert.equal(JSON.stringify(first).includes('cipher'), false);
assert.equal(JSON.stringify(first).includes('key'), false);
assert.deepEqual(
await runtime.localSecretMetadata.listLocalSecretMetadata({
projectId: 'default',
limit: 2,
after: first.next,
}),
{
secrets: [
{
projectId: 'default',
name: 'gamma',
currentVersion: 1,
createdAtMs: 104,
},
],
truncated: false,
},
);
await runtime.close();
await assert.rejects(
runtime.localSecretMetadata.listLocalSecretMetadata({
projectId: 'default',
limit: 1,
}),
{ name: 'LocalSecretMetadataUnavailableError' },
);
});
test('rejects widened and over-budget metadata queries before storage', async () => {
const source = Object.create(
require('../dist/security/secretMetadataRepository.js')
.LocalSqliteSecretMetadataRepository.prototype,
);
assert.throws(
() =>
source.listLocalSecretMetadata({
projectId: 'default',
limit: 65,
}),
/options are invalid/u,
);
});
@@ -76,6 +76,36 @@ export interface LocalSecretEnvironmentProvider {
}): Promise<readonly string[] | null>;
}
export interface LocalSecretMetadata {
readonly projectId: string;
readonly name: string;
readonly currentVersion: number;
readonly createdAtMs: number;
}
export interface LocalSecretMetadataPage {
readonly secrets: readonly Readonly<LocalSecretMetadata>[];
readonly truncated: boolean;
readonly next?: Readonly<{ readonly name: string }>;
}
export interface LocalSecretMetadataSource {
listLocalSecretMetadata(options: {
readonly projectId: string;
readonly limit: number;
readonly after?: Readonly<{ readonly name: string }>;
}): Promise<Readonly<LocalSecretMetadataPage>>;
}
export class LocalSecretMetadataUnavailableError extends Error {
readonly code = 'LOCAL_SECRET_METADATA_UNAVAILABLE';
constructor() {
super('Local Secret metadata is unavailable');
this.name = 'LocalSecretMetadataUnavailableError';
}
}
export interface PutEncryptedLocalSecretCommand {
readonly projectId: string;
readonly name: string;
@@ -151,7 +181,9 @@ function assertIdentifier(
}
}
export function assertLocalSecretProjectId(value: unknown): asserts value is string {
export function assertLocalSecretProjectId(
value: unknown,
): asserts value is string {
assertIdentifier('projectId', value, 128);
}
@@ -159,7 +191,9 @@ export function assertLocalSecretName(value: unknown): asserts value is string {
assertIdentifier('name', value, MAX_LOCAL_SECRET_NAME_BYTES);
}
export function assertLocalSecretVersion(value: unknown): asserts value is number {
export function assertLocalSecretVersion(
value: unknown,
): asserts value is number {
if (
!Number.isSafeInteger(value) ||
(value as number) < 1 ||
@@ -169,11 +203,15 @@ export function assertLocalSecretVersion(value: unknown): asserts value is numbe
}
}
export function assertLocalSecretMutationId(value: unknown): asserts value is string {
export function assertLocalSecretMutationId(
value: unknown,
): asserts value is string {
assertIdentifier('mutationId', value, MAX_LOCAL_SECRET_MUTATION_ID_BYTES);
}
export function assertLocalSecretKeyId(value: unknown): asserts value is string {
export function assertLocalSecretKeyId(
value: unknown,
): asserts value is string {
if (
typeof value !== 'string' ||
value.length === 0 ||
@@ -184,7 +222,9 @@ export function assertLocalSecretKeyId(value: unknown): asserts value is string
}
}
export function assertLocalSecretPlaintext(value: unknown): asserts value is string {
export function assertLocalSecretPlaintext(
value: unknown,
): asserts value is string {
if (
typeof value !== 'string' ||
value.includes('\0') ||
@@ -194,7 +234,9 @@ export function assertLocalSecretPlaintext(value: unknown): asserts value is str
}
}
export function assertLocalSecretExpectedVersion(value: unknown): asserts value is number {
export function assertLocalSecretExpectedVersion(
value: unknown,
): asserts value is number {
if (
!Number.isSafeInteger(value) ||
(value as number) < 0 ||