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,)) 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, ' diff --git a/infrabox/test/registry-auth/test.py b/infrabox/test/registry-auth/test.py index 9c9388f32..dc3512d34 100644 --- a/infrabox/test/registry-auth/test.py +++ b/infrabox/test/registry-auth/test.py @@ -28,7 +28,7 @@ def setUp(self): cur = conn.cursor() cur.execute('TRUNCATE auth_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() diff --git a/src/api/handlers/projects/secrets.py b/src/api/handlers/projects/secrets.py index dd9f1c914..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 @@ -7,12 +10,28 @@ 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', 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), @@ -110,3 +129,158 @@ 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), +}) + +read_token_model = api.model('SecretReadToken', { + 'token': fields.String(required=True), + 'expires_at': fields.String(required=True), +}) + + +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) + def get(self, project_id): + ''' + Returns project's secrets with decrypted values. + + 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.') + + project = g.db.execute_one_dict(''' + SELECT id FROM project WHERE id = %s + ''', [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 + ''', [project_id]) + + for secret in secrets: + secret['value'] = decrypt_secret(secret['value']) + + return secrets 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 abecd4fee..37e13071d 100644 --- a/src/openpolicyagent/policies/projects_secrets.rego +++ b/src/openpolicyagent/policies/projects_secrets.rego @@ -20,6 +20,22 @@ 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/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"