Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion infrabox/test-registry/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,))
Expand Down
2 changes: 1 addition & 1 deletion infrabox/test/api/test_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, '
Expand Down
2 changes: 1 addition & 1 deletion infrabox/test/registry-auth/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
176 changes: 175 additions & 1 deletion src/api/handlers/projects/secrets.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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/<project_id>/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),
Expand Down Expand Up @@ -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
13 changes: 13 additions & 0 deletions src/db/migrations/00046.sql
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions src/db/migrations/00048.sql
Original file line number Diff line number Diff line change
@@ -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/<project_id>/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);
16 changes: 16 additions & 0 deletions src/openpolicyagent/policies/projects_secrets.rego
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ allow {
projects_secrets_administrator([api.token.user.id, project_id])
}

# Allow GET access to /api/v1/projects/<project_id>/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/<project_id>/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/<project_id>/secrets for project administrators
allow {
api.method = "POST"
Expand Down
Loading