feat: let administrators view and copy encrypted integration credentials
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user