From ec2af808dbfb989c130044fb26c1ae28f3b73a39 Mon Sep 17 00:00:00 2001 From: Jiachen Fan Date: Thu, 16 Jul 2026 16:33:38 +0800 Subject: [PATCH 1/6] feat(DM01-6092): add endpoint to get project secrets with decrypted values Add GET /api/v1/projects//secrets/values returning each secret's name and decrypted plaintext value for a project. - New SecretValues resource: validates project uuid (400), returns 404 when the project does not exist, decrypts each secret value. - New OPA rule allowing GET on the .../secrets/values path, reusing the existing project-administrator gate (user token + role >= 20). WARNING: this endpoint exposes plaintext secret values; access is restricted to project administrators by the OPA policy. --- src/api/handlers/projects/secrets.py | 41 ++++++++++++++++++- .../policies/projects_secrets.rego | 8 ++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/api/handlers/projects/secrets.py b/src/api/handlers/projects/secrets.py index dd9f1c914..ac6bc9237 100644 --- a/src/api/handlers/projects/secrets.py +++ b/src/api/handlers/projects/secrets.py @@ -7,7 +7,7 @@ from pyinfrabox.utils import validate_uuid from pyinfraboxutils.ibflask import OK from pyinfraboxutils.ibrestplus import api, response_model -from pyinfraboxutils.secrets import encrypt_secret +from pyinfraboxutils.secrets import encrypt_secret, decrypt_secret ns = api.namespace('Secrets', path='/api/v1/projects//secrets', @@ -110,3 +110,42 @@ def delete(self, project_id, secret_id): g.db.commit() return OK('Successfully deleted secret.') + + +secret_value_model = api.model('SecretValue', { + 'name': fields.String(required=True), + 'value': fields.String(required=True), +}) + + +@ns.route('/values') +@api.doc(responses={403: 'Not Authorized', 404: 'Project not found'}) +class SecretValues(Resource): + + @api.marshal_list_with(secret_value_model) + def get(self, project_id): + ''' + Returns project's secrets with decrypted values. + + WARNING: this endpoint exposes plaintext secret values and is + restricted to project administrators by the OPA policy. + ''' + if not validate_uuid(project_id): + abort(400, 'Invalid project uuid.') + + project = g.db.execute_one_dict(''' + SELECT id FROM project WHERE id = %s + ''', [project_id]) + + if not project: + abort(404, 'Project not found.') + + secrets = g.db.execute_many_dict(''' + SELECT name, value FROM secret + WHERE project_id = %s + ''', [project_id]) + + for secret in secrets: + secret['value'] = decrypt_secret(secret['value']) + + return secrets diff --git a/src/openpolicyagent/policies/projects_secrets.rego b/src/openpolicyagent/policies/projects_secrets.rego index abecd4fee..dbd40e56e 100644 --- a/src/openpolicyagent/policies/projects_secrets.rego +++ b/src/openpolicyagent/policies/projects_secrets.rego @@ -20,6 +20,14 @@ allow { projects_secrets_administrator([api.token.user.id, project_id]) } +# Allow GET access to /api/v1/projects//secrets/values for project administrators +allow { + api.method = "GET" + api.path = ["api", "v1", "projects", project_id, "secrets", "values"] + api.token.type = "user" + projects_secrets_administrator([api.token.user.id, project_id]) +} + # Allow POST access to /api/v1/projects//secrets for project administrators allow { api.method = "POST" From 1f818f5c50e0b0bf078f8456aba54ce5f623c4b0 Mon Sep 17 00:00:00 2001 From: Jiachen Fan Date: Tue, 21 Jul 2026 18:02:21 +0800 Subject: [PATCH 2/6] feat(DM01-6092): add endpoint to get project secrets with decrypted values behind a temporary read-token Adds an apply-first, time-limited two-factor gate on reading plaintext secret values. - GET .../secrets/values: returns name + decrypted value per secret (400 bad uuid, 404 missing project). - POST .../secrets/read-token: project admins mint a 20-minute token; raw token returned once, only its SHA-256 hash stored (secret_read_token table, migration 00048). - Reading values now also requires a valid, unexpired read-token in the X-Secret-Read-Token header, bound to the project and the requesting user. - OPA rule for the new POST path reusing the project-administrator gate. - Placeholder migration 00046 fills a pre-existing numbering gap so migrate.py does not skip 00048 on databases at schema_version 47. - Expiry computed in Python (naive UTC) to stay consistent with the validation clock regardless of DB session timezone; last_used_at tracked best-effort. --- src/api/handlers/projects/secrets.py | 141 +++++++++++++++++- src/db/migrations/00046.sql | 13 ++ src/db/migrations/00048.sql | 25 ++++ .../policies/projects_secrets.rego | 8 + 4 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 src/db/migrations/00046.sql create mode 100644 src/db/migrations/00048.sql diff --git a/src/api/handlers/projects/secrets.py b/src/api/handlers/projects/secrets.py index ac6bc9237..a007c5dac 100644 --- a/src/api/handlers/projects/secrets.py +++ b/src/api/handlers/projects/secrets.py @@ -1,4 +1,7 @@ import re +import hashlib +import secrets as secrets_lib +from datetime import datetime, timezone, timedelta from flask import request, g, abort from flask_restx import Resource, fields @@ -13,6 +16,22 @@ path='/api/v1/projects//secrets', description='Secret related operations') +# Temporary read-token for decrypted secret values. +# Format: ib_secret_read_<48 hex chars>; lookup key is the first 16 hex chars +# of the suffix; only the SHA-256 hash of the raw token is stored. +_READ_TOKEN_PREFIX = 'ib_secret_read_' +_READ_TOKEN_HEADER = 'X-Secret-Read-Token' +_READ_TOKEN_TTL_MINUTES = 20 + + +def _utcnow_naive(): + '''Naive UTC datetime for comparing against psycopg2 TIMESTAMP values.''' + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _hash_read_token(raw_token): + return hashlib.sha256(raw_token.encode('utf-8')).hexdigest() + secret_model = api.model('Secret', { 'name': fields.String(required=True), 'id': fields.String(required=True), @@ -117,9 +136,121 @@ def delete(self, project_id, secret_id): 'value': fields.String(required=True), }) +read_token_model = api.model('SecretReadToken', { + 'token': fields.String(required=True), + 'expires_at': fields.String(required=True), +}) -@ns.route('/values') + +def _validate_read_token(project_id): + ''' + Validate the temporary read-token presented in the X-Secret-Read-Token + header for the given project. Aborts with 401/403 when invalid; returns + the token row on success. + + This is the second factor for reading decrypted secret values: OPA has + already confirmed the caller is a project administrator, and this confirms + the caller holds a valid, unexpired read-token bound to this project and + to themselves. + ''' + raw_token = request.headers.get(_READ_TOKEN_HEADER, '') + + if not raw_token.startswith(_READ_TOKEN_PREFIX): + abort(401, 'A secret read-token is required. Apply for one first.') + + token_suffix = raw_token[len(_READ_TOKEN_PREFIX):] + if len(token_suffix) != 48: + abort(401, 'Invalid read-token format.') + + token_id = token_suffix[:16] + token_hash = _hash_read_token(raw_token) + + row = g.db.execute_one_dict(''' + SELECT token_id, project_id, user_id, expires_at, revoked_at + FROM secret_read_token + WHERE token_id = %s AND token_hash = %s + ''', [token_id, token_hash]) + + if not row: + abort(401, 'Invalid or unknown read-token.') + + if str(row['project_id']) != str(project_id): + abort(403, 'Read-token is not valid for this project.') + + if str(row['user_id']) != str(g.token['user']['id']): + abort(403, 'Read-token does not belong to the current user.') + + if row['revoked_at'] is not None: + abort(401, 'Read-token has been revoked.') + + if row['expires_at'] < _utcnow_naive(): + abort(401, 'Read-token has expired. Apply for a new one.') + + # Track usage (best-effort; must not fail the read). Useful for auditing + # who actually read plaintext secret values and when. + try: + g.db.execute( + 'UPDATE secret_read_token SET last_used_at = NOW() WHERE token_id = %s', + [row['token_id']] + ) + g.db.commit() + except Exception: + pass + + return row + + +@ns.route('/read-token') @api.doc(responses={403: 'Not Authorized', 404: 'Project not found'}) +class SecretReadToken(Resource): + + @api.response(201, 'Created', read_token_model) + def post(self, project_id): + ''' + Apply for a temporary read-token for this project's secret values. + + Restricted to project administrators by the OPA policy. The returned + token is valid for 20 minutes and must be presented in the + X-Secret-Read-Token header when reading decrypted values. The raw + token is shown only once; only its hash is stored. + ''' + if not validate_uuid(project_id): + abort(400, 'Invalid project uuid.') + + project = g.db.execute_one_dict(''' + SELECT id FROM project WHERE id = %s + ''', [project_id]) + + if not project: + abort(404, 'Project not found.') + + raw_suffix = secrets_lib.token_hex(24) # 48 hex chars + raw_token = _READ_TOKEN_PREFIX + raw_suffix + token_id = raw_suffix[:16] + token_hash = _hash_read_token(raw_token) + + # Compute expiry in Python as naive UTC so it shares the same clock as + # the validation check (_utcnow_naive), independent of the DB session + # timezone. Mirrors the mcp_token creation path. + expires_at = _utcnow_naive() + timedelta(minutes=_READ_TOKEN_TTL_MINUTES) + + row = g.db.execute_one_dict(''' + INSERT INTO secret_read_token (token_id, token_hash, project_id, user_id, expires_at) + VALUES (%s, %s, %s, %s, %s) + RETURNING expires_at + ''', [token_id, token_hash, project_id, g.token['user']['id'], expires_at]) + g.db.commit() + + return { + 'token': raw_token, # shown once only + 'expires_at': row['expires_at'].isoformat(), + }, 201 + + +@ns.route('/values') +@api.doc(responses={401: 'Read-token required/invalid/expired', + 403: 'Not Authorized', + 404: 'Project not found'}) class SecretValues(Resource): @api.marshal_list_with(secret_value_model) @@ -127,8 +258,10 @@ def get(self, project_id): ''' Returns project's secrets with decrypted values. - WARNING: this endpoint exposes plaintext secret values and is - restricted to project administrators by the OPA policy. + WARNING: this endpoint exposes plaintext secret values. Access requires + BOTH project-administrator role (enforced by the OPA policy) AND a + valid, unexpired temporary read-token in the X-Secret-Read-Token header + (applied for via POST .../secrets/read-token). ''' if not validate_uuid(project_id): abort(400, 'Invalid project uuid.') @@ -140,6 +273,8 @@ def get(self, project_id): if not project: abort(404, 'Project not found.') + _validate_read_token(project_id) + secrets = g.db.execute_many_dict(''' SELECT name, value FROM secret WHERE project_id = %s diff --git a/src/db/migrations/00046.sql b/src/db/migrations/00046.sql new file mode 100644 index 000000000..db49fbdf0 --- /dev/null +++ b/src/db/migrations/00046.sql @@ -0,0 +1,13 @@ +-- Intentionally empty placeholder migration. +-- +-- Historically the migration sequence skipped 00046 (00045 was followed +-- directly by 00047). The migration runner (migrate.py) selects pending +-- migrations by slicing the sorted file list with the stored schema_version +-- as an index, which assumes file numbers are contiguous. The gap made the +-- file number and list index diverge, causing later migrations (e.g. 00048) +-- to be skipped on databases already at schema_version 47. +-- +-- This placeholder fills the gap so the numbering is contiguous again and the +-- number/index alignment is restored. It performs no schema change: +-- migrate.py strips the file contents and only executes when non-empty, so a +-- comment-only file is a safe no-op that still advances schema_version. diff --git a/src/db/migrations/00048.sql b/src/db/migrations/00048.sql new file mode 100644 index 000000000..cfd106332 --- /dev/null +++ b/src/db/migrations/00048.sql @@ -0,0 +1,25 @@ +-- Temporary read-tokens for decrypted secret values. +-- +-- A project administrator applies for one of these tokens; it is valid for a +-- short, fixed window (20 minutes) and is required, in addition to the normal +-- admin session token, to read decrypted secret values via +-- GET /api/v1/projects//secrets/values. +-- +-- Token format: ib_secret_read_<48 hex chars> +-- Lookup key: first 16 chars of the 48-char hex suffix (token_id) +-- Hash: SHA-256 of the full raw token string (UTF-8) — raw token is +-- never stored. +CREATE TABLE secret_read_token ( + token_id VARCHAR(16) NOT NULL, + token_hash VARCHAR(64) NOT NULL, + project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + expires_at TIMESTAMP NOT NULL, + revoked_at TIMESTAMP, + last_used_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY (token_id) +); + +CREATE INDEX idx_secret_read_token_hash ON secret_read_token(token_hash); +CREATE INDEX idx_secret_read_token_project ON secret_read_token(project_id, user_id); diff --git a/src/openpolicyagent/policies/projects_secrets.rego b/src/openpolicyagent/policies/projects_secrets.rego index dbd40e56e..37e13071d 100644 --- a/src/openpolicyagent/policies/projects_secrets.rego +++ b/src/openpolicyagent/policies/projects_secrets.rego @@ -28,6 +28,14 @@ allow { projects_secrets_administrator([api.token.user.id, project_id]) } +# Allow POST access to /api/v1/projects//secrets/read-token for project administrators +allow { + api.method = "POST" + api.path = ["api", "v1", "projects", project_id, "secrets", "read-token"] + api.token.type = "user" + projects_secrets_administrator([api.token.user.id, project_id]) +} + # Allow POST access to /api/v1/projects//secrets for project administrators allow { api.method = "POST" From 67ec60ac916b71d2b8dfe9d72c4c5d5109dcaaa9 Mon Sep 17 00:00:00 2001 From: Jiachen Fan Date: Wed, 22 Jul 2026 10:15:36 +0800 Subject: [PATCH 3/6] fix(DM01-6092): add secret_read_token to test TRUNCATE list secret_read_token has a FK to project(id). Postgres refuses to TRUNCATE project while a referencing table is not also truncated, which raised 'cannot truncate a table referenced in a foreign key constraint' on the first setUp TRUNCATE, poisoning the shared test connection and cascading InFailedSqlTransaction into all subsequent api-tests. Truncate the child table first. --- infrabox/test/api/test_template.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrabox/test/api/test_template.py b/infrabox/test/api/test_template.py index 8ee7aa3de..a23220753 100644 --- a/infrabox/test/api/test_template.py +++ b/infrabox/test/api/test_template.py @@ -8,7 +8,7 @@ class ApiTestTemplate(unittest.TestCase): def setUp(self): TestClient.execute( - 'TRUNCATE mcp_access_log, mcp_token, ' + 'TRUNCATE secret_read_token, mcp_access_log, mcp_token, ' 'global_token_access_log, global_token, ' 'collaborator, auth_token, secret, ' 'console, job_markup, job_badge, job, ' From 4f215ed4f2b6d7123aa95acc2045043147963173 Mon Sep 17 00:00:00 2001 From: Jiachen Fan Date: Wed, 22 Jul 2026 10:38:54 +0800 Subject: [PATCH 4/6] fix(DM01-6092): truncate secret_read_token in registry-auth test setUp The registry-auth test (also used by the docker-registry job) does TRUNCATE project in setUp; secret_read_token's FK to project blocked it with the same 'cannot truncate a table referenced in a foreign key constraint' error. Truncate the child table first, mirroring the api test_template fix. --- infrabox/test/registry-auth/test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/infrabox/test/registry-auth/test.py b/infrabox/test/registry-auth/test.py index 9c9388f32..001a157ef 100644 --- a/infrabox/test/registry-auth/test.py +++ b/infrabox/test/registry-auth/test.py @@ -28,6 +28,7 @@ def setUp(self): cur = conn.cursor() cur.execute('TRUNCATE auth_token') + cur.execute('TRUNCATE secret_read_token') cur.execute('TRUNCATE project') cur.execute('''INSERT INTO project(name, type, id) VALUES('test', 'upload', %s)''', (self.project_id,)) From 294b697c5c647d2db4742eda09518ffaa1d30aae Mon Sep 17 00:00:00 2001 From: Jiachen Fan Date: Wed, 22 Jul 2026 10:50:12 +0800 Subject: [PATCH 5/6] fix(DM01-6092): truncate secret_read_token and project in one statement Postgres refuses TRUNCATE project as long as secret_read_token's FK references it, regardless of prior separate truncates. Splitting into two TRUNCATE statements does not help; they must be truncated in a single statement (TRUNCATE secret_read_token, project), matching the HINT postgres emits and the api test_template approach. --- infrabox/test/registry-auth/test.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/infrabox/test/registry-auth/test.py b/infrabox/test/registry-auth/test.py index 001a157ef..dc3512d34 100644 --- a/infrabox/test/registry-auth/test.py +++ b/infrabox/test/registry-auth/test.py @@ -28,8 +28,7 @@ def setUp(self): cur = conn.cursor() cur.execute('TRUNCATE auth_token') - cur.execute('TRUNCATE secret_read_token') - cur.execute('TRUNCATE project') + cur.execute('TRUNCATE secret_read_token, project') cur.execute('''INSERT INTO project(name, type, id) VALUES('test', 'upload', %s)''', (self.project_id,)) opa_push_all() From a0ebf8f17bd7517d3a68ba903f81ffaf2b5a91da Mon Sep 17 00:00:00 2001 From: Jiachen Fan Date: Wed, 22 Jul 2026 11:01:21 +0800 Subject: [PATCH 6/6] fix(DM01-6092): truncate secret_read_token with project in test-registry Third and final test suite (infrabox/test-registry/test.py, used by the docker-registry job) also did a standalone TRUNCATE project blocked by secret_read_token's FK. Merge into a single TRUNCATE statement. Verified via repo-wide search that no standalone 'TRUNCATE project' remains. --- infrabox/test-registry/test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrabox/test-registry/test.py b/infrabox/test-registry/test.py index d4441a023..15f652fdd 100644 --- a/infrabox/test-registry/test.py +++ b/infrabox/test-registry/test.py @@ -58,7 +58,7 @@ def delete(self, url): def setUp(self): cur = conn.cursor() cur.execute('TRUNCATE auth_token') - cur.execute('TRUNCATE project') + cur.execute('TRUNCATE secret_read_token, project') cur.execute('TRUNCATE collaborator') cur.execute('''INSERT INTO auth_token (id, description, project_id, scope_push, scope_pull) VALUES(%s, 'test token', %s, true, true)''', (self.token, self.project_id,))