feat: let administrators view and copy encrypted integration credentials

This commit is contained in:
2026-09-27 13:48:26 +08:00
parent 5555a7b342
commit d54bdd9847
38 changed files with 905 additions and 71 deletions

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""部署持久化的本地联调环境;私密配置只保存在 .local/compose-test。"""
import datetime
import base64
import json
import os
from pathlib import Path
@@ -86,12 +87,22 @@ def setup():
'ORBIT_BUILD_TIME': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')})
private_write(PRIVATE / 'admin.password', secrets.token_urlsafe(24) + '\n')
values = settings()
key_file = PRIVATE / 'credential-keys.json'
if not key_file.exists():
# 已启用后丢失主密钥,必须恢复备份,不能生成替代密钥使已有密文不可读。
key_volumes = subprocess.check_output(['docker', 'volume', 'ls', '--filter', 'name=orbit-test_orbit-keys', '-q'], text=True).strip()
if values.get('ORBIT_CREDENTIAL_VAULT_INITIALIZED') or key_volumes:
raise RuntimeError('Token 保管密钥缺失,请恢复 credential-keys.json;不会替换既有密钥。')
private_write(key_file, json.dumps({'active': 'local-v1', 'keys': {'local-v1': base64.b64encode(secrets.token_bytes(32)).decode()}}) + '\n')
values['ORBIT_CREDENTIAL_VAULT_INITIALIZED'] = '1'
values['ORBIT_BUILD_REVISION'] = subprocess.check_output(
['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip()
save_settings(values)
compose('config', '--quiet')
compose('build', 'orbit')
compose('up', '-d', 'mysql', 'verify', 'archive-init')
# 完成构建后再优雅停止旧写节点,避免迁移期间混用凭据存储版本。
compose('stop', 'orbit')
compose('up', '-d', 'mysql', 'verify', 'archive-init', 'credential-key-init')
# 显式再核验一次,失败不初始化身份、不启动流量。
compose('run', '--rm', '-T', '--no-deps', 'verify')
if not CREDENTIALS.exists():

View File

@@ -32,6 +32,8 @@ def call(path, body=None, token=None, headers=None, expected=200, session=True):
response = error
with response:
status = response.status
if path.startswith("/console/applications"):
assert response.headers.get("Cache-Control") == "no-store"
value = json.load(response)
assert status == expected, f'{path}: HTTP {status}, expected {expected}; code={value.get("error", {}).get("code")}'
return value.get('data', value)
@@ -60,6 +62,16 @@ page = call('/console/applications')
assert any(x['namespace_id'] == op['namespace_id'] for x in page['items'])
detail = call('/console/applications/' + op['namespace_id'])
assert op['token'] not in json.dumps(detail)
assert detail['token_vault_enabled'] and detail['credentials'][0]['token_stored']
token_path = '/console/applications/' + op['namespace_id'] + '/credentials/' + op['credential_id'] + '/token/'
assert call(token_path + 'reveal', {'expected_revision':'1'})['token'] == op['token']
call(token_path + 'reveal', {'expected_revision':'1'}, token=op['token'], session=False, expected=401)
call(token_path + 'reveal', {'expected_revision':'1'}, headers={'Origin':'https://other.invalid'}, expected=403)
call(token_path + 'reveal', {'expected_revision':'1'}, headers={'X-Orbit-Console':''}, expected=403)
call(token_path + 'reveal', {'expected_revision':'1'}, headers={'Sec-Fetch-Site':'cross-site'}, expected=403)
call(token_path + 'reveal', {'expected_revision':'2'}, expected=409)
assert call(token_path + 'import', {'expected_revision':'1', 'token':op['token']})['token'] == op['token']
queue = detail['queues'][0]['id']
assert queue == op['queue_id']
call('/console/applications', token=op['token'], expected=401, session=False)
@@ -85,12 +97,17 @@ assert call('/console/applications/apply', rotate)['deduplicated']
call('/v1/queues/' + queue, token=op['token'], session=False)
call('/v1/queues/' + queue, token=rotate['token'], session=False)
assert call('/console/applications/' + op['namespace_id'])['credentials'][0]['previous_valid_until']
assert call(token_path + 'reveal', {'expected_revision':'3'})['token'] == rotate['token']
call(token_path + 'import', {'expected_revision':'3', 'token':op['token']}, expected=400)
call(token_path + 'reveal', {'expected_revision':'2'}, expected=409)
finalize = call('/console/applications/prepare', {'kind': 'FINALIZE', 'namespace_id': op['namespace_id'], 'credential_id': op['credential_id'], 'expected_revision': '3', 'valid_days': 0, 'reason': 'all services switched'})
if private:
Path(private, 'finalize.operation.json').write_text(json.dumps(finalize))
assert 'token' not in finalize
assert call('/console/applications/apply', finalize)['revision'] == '4'
assert call('/console/applications/apply', finalize)['deduplicated']
assert call(token_path + 'reveal', {'expected_revision':'4'})['token'] == rotate['token']
call('/v1/queues/' + queue, token=op['token'], expected=401, session=False)
call('/v1/queues/' + queue, token=rotate['token'], session=False)
# 重放旧轮换回执不能重新启用已经结束过渡的秘密。
@@ -135,7 +152,8 @@ assert call('/v1/subscriptions/' + subscription_id, token=rotate['token'], sessi
assert call('/console/scope')['entry']['namespace_id'] == original_scope['entry']['namespace_id']
print('PASS: scoped task/schedule/workflow/subscription/retention/archive reads, subscription management/replay, SDK ownership, CSRF and cross-application isolation')
call('/console/auth/logout', {})
call(token_path + 'reveal', {'expected_revision':'4'}, expected=401)
call('/console/applications', expected=401)
call('/v1/tasks/' + task_id, headers=scope_headers, expected=401)
print('PASS: application create/replay, CSRF/session isolation, permanent expiry, overlapping rotation/finalization, original task/subscription preserved, logout')
print('PASS: encrypted Token reveal/import, session/CSRF/no-store/current-generation checks, application create/replay, CSRF/session isolation, permanent expiry, overlapping rotation/finalization, original task/subscription preserved, logout')
print('Test application: ' + name)

View File

@@ -46,6 +46,13 @@ E2E_APPLICATION="console-e2e-$E2E_SUFFIX"
E2E_ADMIN="orbit-admin-$E2E_SUFFIX"
printf '%s\n' "$E2E_CONSOLE_PASSWORD" > "$E2E_PASSWORD_FILE"
python3 - "$E2E_DIR/credential-keys.json" <<'PY_KEY'
import base64, json, pathlib, secrets, sys
path = pathlib.Path(sys.argv[1])
path.write_text(json.dumps({'active':'test-v1', 'keys':{'test-v1':base64.b64encode(secrets.token_bytes(32)).decode()}}))
path.chmod(0o600)
PY_KEY
printf '%s\n' "[1/8] build image"
"$E2E_DOCKER" build --tag "$E2E_IMAGE" . >/dev/null
"$E2E_DOCKER" run -d --name "$E2E_MYSQL" --network bridge \
@@ -76,6 +83,7 @@ printf '%s\n' "[3/8] bootstrap resources and console administrator"
"$E2E_DOCKER" run --rm -i --network bridge --link "$E2E_MYSQL:mysql" \
--env ORBIT_MYSQL_DSN="$E2E_DSN" \
--volume "$E2E_DIR:/run/orbit" \
--env ORBIT_CREDENTIAL_KEY_FILE=/run/orbit/credential-keys.json \
--entrypoint /usr/local/bin/orbit-admin "$E2E_IMAGE" bootstrap \
--application "$E2E_APPLICATION" \
--namespace default \
@@ -87,6 +95,7 @@ printf '%s\n' "[3/8] bootstrap resources and console administrator"
--console-display-name 'Orbit E2E 管理员' \
--console-password-stdin \
--output /run/orbit/credentials.json < "$E2E_PASSWORD_FILE" >/dev/null
"$E2E_DOCKER" run --rm --user 0:0 --volume "$E2E_DIR:/run/orbit" --entrypoint /bin/sh mysql:8.4 -c 'chown 65532:65532 /run/orbit/credential-keys.json'
E2E_QUEUE=$(python3 - "$E2E_CREDENTIALS_FILE" <<'PY'
import json
import sys
@@ -107,6 +116,8 @@ printf '%s\n' "[4/8] start Orbit container"
--env ORBIT_HTTP_ADDR=0.0.0.0:8080 \
--env ORBIT_CONSOLE_QUEUE_ID="$E2E_QUEUE" \
--env ORBIT_CONSOLE_APPLICATION_ADMIN="$E2E_ADMIN" \
--env ORBIT_CREDENTIAL_KEY_FILE=/run/credential-keys.json \
--volume "$E2E_DIR/credential-keys.json:/run/credential-keys.json:ro" \
--env ORBIT_ARCHIVE_ROOT=/var/lib/orbit/archive \
--env ORBIT_ARCHIVE_STORAGE_ID="$E2E_STORAGE_ID" \
--env ORBIT_ARCHIVE_TICK_INTERVAL=1s \