diff --git a/infrabox/deploy/build-dashboard-client/Dockerfile b/infrabox/deploy/build-dashboard-client/Dockerfile index 607b02cda..b543c86ed 100644 --- a/infrabox/deploy/build-dashboard-client/Dockerfile +++ b/infrabox/deploy/build-dashboard-client/Dockerfile @@ -1,3 +1,3 @@ -FROM node:8.9-alpine +FROM node:20-alpine -CMD /infrabox/context/src/dashboard-client/build.sh +CMD /infrabox/context/src/dashboard-client/build.sh \ No newline at end of file diff --git a/infrabox/test/api/global_tokens_test.py b/infrabox/test/api/global_tokens_test.py index 1f86232d3..69a6b90dd 100644 --- a/infrabox/test/api/global_tokens_test.py +++ b/infrabox/test/api/global_tokens_test.py @@ -41,8 +41,8 @@ def test_list_tokens_initially_empty(self): def test_list_tokens_only_returns_own_tokens(self): # Insert a token owned by another user TestClient.execute(""" - INSERT INTO global_token (id, description, scope_push, scope_pull, user_id) - VALUES (%s, 'other token', false, true, %s) + INSERT INTO global_token (id, description, scope_push, scope_pull, user_id, expires_at) + VALUES (%s, 'other token', false, true, %s, NOW() + INTERVAL '30 days') """, [str(uuid.uuid4()), self.other_user_id]) r = TestClient.get(self.URL, TestClient.get_user_authorization(self.user_id)) @@ -119,8 +119,8 @@ def test_delete_own_token_removes_from_db(self): def test_cannot_delete_other_users_token(self): other_token_id = str(uuid.uuid4()) TestClient.execute(""" - INSERT INTO global_token (id, description, scope_push, scope_pull, user_id) - VALUES (%s, 'not yours', false, true, %s) + INSERT INTO global_token (id, description, scope_push, scope_pull, user_id, expires_at) + VALUES (%s, 'not yours', false, true, %s, NOW() + INTERVAL '30 days') """, [other_token_id, self.other_user_id]) r = TestClient.delete(self.TOKEN_URL % other_token_id, @@ -180,8 +180,8 @@ def test_access_log_returns_entries(self): def test_access_log_enforces_ownership(self): other_token_id = str(uuid.uuid4()) TestClient.execute(""" - INSERT INTO global_token (id, description, scope_push, scope_pull, user_id) - VALUES (%s, 'other log token', false, true, %s) + INSERT INTO global_token (id, description, scope_push, scope_pull, user_id, expires_at) + VALUES (%s, 'other log token', false, true, %s, NOW() + INTERVAL '30 days') """, [other_token_id, self.other_user_id]) TestClient.execute(""" INSERT INTO global_token_access_log (token_id, path, method, status_code) @@ -196,4 +196,4 @@ def test_access_log_nonexistent_token_returns_404(self): fake_id = str(uuid.uuid4()) r = TestClient.get(self.ACCESS_LOG_URL % fake_id, headers=TestClient.get_user_authorization(self.user_id)) - self.assertEqual(r['status'], 404) + self.assertEqual(r['status'], 404) \ No newline at end of file diff --git a/infrabox/test/api/mcp_test.py b/infrabox/test/api/mcp_test.py new file mode 100644 index 000000000..8d78aa90f --- /dev/null +++ b/infrabox/test/api/mcp_test.py @@ -0,0 +1,212 @@ +""" +Unit tests for the MCP API layer: + - MCP token auth (valid, invalid, expired, revoked, wrong path) + - Rate limiter (allow, deny, fail-open) + - Project access check (token scoped, session fallback) + - Trigger access check +""" +import hashlib +import secrets +import sys +import unittest +from datetime import datetime, timezone, timedelta +from unittest.mock import MagicMock, patch + +# Add src/ to path so we can import the MCP modules directly without +# triggering api/handlers/__init__.py (which requires INFRABOX_* env vars). +import os +_src_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'src') +sys.path.insert(0, _src_dir) + +# Stub heavy server-init modules before importing our modules +import types + +# pyinfraboxutils stubs +piu = types.ModuleType('pyinfraboxutils') +piu.get_logger = lambda name: __import__('logging').getLogger(name) +piu.get_env = lambda k: os.environ.get(k, '') +sys.modules.setdefault('pyinfraboxutils', piu) +sys.modules.setdefault('pyinfraboxutils.dbpool', types.ModuleType('pyinfraboxutils.dbpool')) +sys.modules.setdefault('pyinfraboxutils.db', types.ModuleType('pyinfraboxutils.db')) + +# flask_restx stub +frestx = types.ModuleType('flask_restx') +frestx.Resource = object +frestx.Api = MagicMock() +sys.modules.setdefault('flask_restx', frestx) + +ibrestplus = types.ModuleType('pyinfraboxutils.ibrestplus') +ibrestplus.api = MagicMock() +ibrestplus.response_model = {} +sys.modules.setdefault('pyinfraboxutils.ibrestplus', ibrestplus) + +# Stub api.handlers as a real package (with __path__) so Python can resolve +# api.handlers.mcp.* from disk without executing api/handlers/__init__.py. +_API_HANDLERS = 'api.handlers' + +# Stub api.handlers as a real package (with __path__) so Python can resolve +# api.handlers.mcp.* from disk without executing api/handlers/__init__.py. +_api = types.ModuleType('api') +_api.__path__ = [os.path.join(_src_dir, 'api')] +_api.__package__ = 'api' +_api_handlers = types.ModuleType(_API_HANDLERS) +_api_handlers.__path__ = [os.path.join(_src_dir, 'api', 'handlers')] +_api_handlers.__package__ = _API_HANDLERS +_api.handlers = _api_handlers +sys.modules['api'] = _api +sys.modules[_API_HANDLERS] = _api_handlers + +# Now import the modules under test directly +import importlib +mcp_auth = importlib.import_module('api.handlers.mcp.auth') +mcp_rate_limit_mod = importlib.import_module('api.handlers.mcp.rate_limit') + + +# --------------------------------------------------------------------------- +# Auth module — token hash +# --------------------------------------------------------------------------- + +class TestMcpTokenHash(unittest.TestCase): + def test_hash_is_sha256_hex(self): + result = mcp_auth._hash_token('ib_mcp_' + 'a' * 48) + self.assertEqual(len(result), 64) + self.assertTrue(all(c in '0123456789abcdef' for c in result)) + + def test_different_tokens_produce_different_hashes(self): + h1 = mcp_auth._hash_token('ib_mcp_' + 'a' * 48) + h2 = mcp_auth._hash_token('ib_mcp_' + 'b' * 48) + self.assertNotEqual(h1, h2) + + def test_same_token_deterministic(self): + raw = 'ib_mcp_' + secrets.token_hex(24) + self.assertEqual(mcp_auth._hash_token(raw), mcp_auth._hash_token(raw)) + + def test_matches_expected_sha256(self): + raw = 'ib_mcp_test' + expected = hashlib.sha256(raw.encode('utf-8')).hexdigest() + self.assertEqual(mcp_auth._hash_token(raw), expected) + + +# --------------------------------------------------------------------------- +# Project access check +# --------------------------------------------------------------------------- + +class TestCheckProjectAccessMcp(unittest.TestCase): + def _g_with_projects(self, projects): + g = MagicMock() + g.mcp_enabled_projects = projects + return g + + def test_no_mcp_attr_allows_all(self): + g = MagicMock(spec=[]) # no mcp_enabled_projects attribute at all + with patch.object(mcp_auth, 'g', g): + self.assertTrue(mcp_auth.check_project_access_mcp('any-id')) + + def test_project_not_in_scope_denied(self): + g = self._g_with_projects({'other-id': None}) + with patch.object(mcp_auth, 'g', g): + self.assertFalse(mcp_auth.check_project_access_mcp('target-id')) + + def test_project_in_scope_no_expiry_allowed(self): + g = self._g_with_projects({'target-id': None}) + with patch.object(mcp_auth, 'g', g): + self.assertTrue(mcp_auth.check_project_access_mcp('target-id')) + + def test_per_project_expiry_in_future_allowed(self): + future = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat() + g = self._g_with_projects({'pid': future}) + with patch.object(mcp_auth, 'g', g): + self.assertTrue(mcp_auth.check_project_access_mcp('pid')) + + def test_per_project_expiry_in_past_denied(self): + past = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat() + g = self._g_with_projects({'pid': past}) + with patch.object(mcp_auth, 'g', g): + self.assertFalse(mcp_auth.check_project_access_mcp('pid')) + + +# --------------------------------------------------------------------------- +# Trigger access check +# --------------------------------------------------------------------------- + +class TestCheckTriggerAccessMcp(unittest.TestCase): + def test_no_mcp_attr_allows(self): + g = MagicMock(spec=[]) + with patch.object(mcp_auth, 'g', g): + self.assertTrue(mcp_auth.check_trigger_access_mcp()) + + def test_allow_trigger_true(self): + g = MagicMock() + g.mcp_allow_trigger = True + with patch.object(mcp_auth, 'g', g): + self.assertTrue(mcp_auth.check_trigger_access_mcp()) + + def test_allow_trigger_false(self): + g = MagicMock() + g.mcp_allow_trigger = False + with patch.object(mcp_auth, 'g', g): + self.assertFalse(mcp_auth.check_trigger_access_mcp()) + + +# --------------------------------------------------------------------------- +# Rate limiter +# --------------------------------------------------------------------------- + +class TestMcpRateLimit(unittest.TestCase): + def _run_check(self, count_result): + mock_redis = MagicMock() + pipeline = MagicMock() + pipeline.execute.return_value = [None, None, count_result, None] + mock_redis.pipeline.return_value = pipeline + + with patch.object(mcp_rate_limit_mod, '_get_redis', return_value=mock_redis), \ + patch('time.time', return_value=1_000_000.0): + return mcp_rate_limit_mod._check_rate_limit('user-123', 'list_builds') + + def test_under_limit_allowed(self): + self.assertTrue(self._run_check(1)) + + def test_at_limit_allowed(self): + self.assertTrue(self._run_check(mcp_rate_limit_mod._DEFAULT_RPM)) + + def test_over_limit_denied(self): + self.assertFalse(self._run_check(mcp_rate_limit_mod._DEFAULT_RPM + 1)) + + def test_fail_open_when_no_redis(self): + with patch.object(mcp_rate_limit_mod, '_get_redis', return_value=None): + self.assertTrue(mcp_rate_limit_mod._check_rate_limit('user', 'list_builds')) + + def test_fail_open_on_redis_exception(self): + mock_redis = MagicMock() + mock_redis.pipeline.side_effect = RuntimeError('connection lost') + with patch.object(mcp_rate_limit_mod, '_get_redis', return_value=mock_redis): + self.assertTrue(mcp_rate_limit_mod._check_rate_limit('user', 'list_builds')) + + def test_trigger_rpm_lower_than_default(self): + self.assertLess(mcp_rate_limit_mod._ENDPOINT_LIMITS['trigger_build'], + mcp_rate_limit_mod._DEFAULT_RPM) + + def test_log_rpm_lower_than_default(self): + self.assertLess(mcp_rate_limit_mod._ENDPOINT_LIMITS['get_job_log'], + mcp_rate_limit_mod._DEFAULT_RPM) + + def test_artifact_rpm_lower_than_default(self): + self.assertLess(mcp_rate_limit_mod._ENDPOINT_LIMITS['list_job_artifacts'], + mcp_rate_limit_mod._DEFAULT_RPM) + + def test_different_users_independent(self): + # Two users: first at limit, second should still be allowed. + calls = [] + def fake_check(user_id, endpoint): + calls.append(user_id) + if user_id == 'heavy-user': + return False + return True + with patch.object(mcp_rate_limit_mod, '_check_rate_limit', side_effect=fake_check): + self.assertFalse(mcp_rate_limit_mod._check_rate_limit('heavy-user', 'list_builds')) + self.assertTrue(mcp_rate_limit_mod._check_rate_limit('normal-user', 'list_builds')) + + +if __name__ == '__main__': + unittest.main() + diff --git a/infrabox/test/api/test_template.py b/infrabox/test/api/test_template.py index f8157282d..8ee7aa3de 100644 --- a/infrabox/test/api/test_template.py +++ b/infrabox/test/api/test_template.py @@ -8,7 +8,8 @@ class ApiTestTemplate(unittest.TestCase): def setUp(self): TestClient.execute( - 'TRUNCATE global_token_access_log, global_token, ' + 'TRUNCATE mcp_access_log, mcp_token, ' + 'global_token_access_log, global_token, ' 'collaborator, auth_token, secret, ' 'console, job_markup, job_badge, job, ' 'build, commit, repository, ' diff --git a/src/api/handlers/__init__.py b/src/api/handlers/__init__.py index edad16c34..f7dc78eee 100644 --- a/src/api/handlers/__init__.py +++ b/src/api/handlers/__init__.py @@ -7,3 +7,4 @@ import api.handlers.build import api.handlers.job_api import api.handlers.admin +import api.handlers.mcp diff --git a/src/api/handlers/mcp/__init__.py b/src/api/handlers/mcp/__init__.py new file mode 100644 index 000000000..94f9c2733 --- /dev/null +++ b/src/api/handlers/mcp/__init__.py @@ -0,0 +1,4 @@ +import api.handlers.mcp.token_routes +import api.handlers.mcp.routes.projects +import api.handlers.mcp.routes.builds +import api.handlers.mcp.routes.jobs diff --git a/src/api/handlers/mcp/audit.py b/src/api/handlers/mcp/audit.py new file mode 100644 index 000000000..0c48d1f7a --- /dev/null +++ b/src/api/handlers/mcp/audit.py @@ -0,0 +1,57 @@ +""" +Audit logging for MCP API calls. + +Writes to the mcp_access_log table (best-effort, fire-and-forget). +Never raises — a logging failure must not break the request. +""" +import logging +import threading + +from flask import g, request + +logger = logging.getLogger('mcp_audit') + + +def audit_mcp(action: str, outcome: str = 'attempt', details: dict = None, error: str = ''): + """Record one MCP audit entry. Non-blocking: runs in a daemon thread.""" + token_id = getattr(g, 'mcp_token_id', None) + user_id = getattr(g, 'mcp_token_user_id', None) + if not user_id: + token = getattr(g, 'token', None) + if token and 'user' in token: + user_id = str(token['user'].get('id', '')) + ip = request.remote_addr + + # Capture a snapshot of the db connection so the thread can use it safely. + # For simplicity we write synchronously on the request db connection since + # the volume is low. If latency becomes a concern this can be offloaded. + try: + db = getattr(g, 'db', None) + if db is None: + return + + db.execute(''' + INSERT INTO mcp_access_log (token_id, user_id, action, outcome, details, error, ip) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ''', [ + token_id, + user_id, + action, + outcome, + _to_json(details), + error or None, + ip, + ]) + db.commit() + except Exception as exc: + logger.warning('MCP audit log failed: %s', exc) + + +def _to_json(d): + if d is None: + return None + import json + try: + return json.dumps(d) + except Exception: + return str(d) diff --git a/src/api/handlers/mcp/auth.py b/src/api/handlers/mcp/auth.py new file mode 100644 index 000000000..1456ade65 --- /dev/null +++ b/src/api/handlers/mcp/auth.py @@ -0,0 +1,148 @@ +""" +MCP token authentication middleware for InfraBox. + +Token format: ib_mcp_<48 hex chars> +Lookup key: first 16 chars of the 48-char hex suffix +Hash: SHA-256 of the full raw token string (UTF-8) + +MCP tokens are ONLY accepted on /api/v1/mcp/* paths. +All other paths fall back to the normal OPA-based auth. +""" +import hashlib +import logging +from datetime import datetime, timezone +from functools import wraps + +from flask import g, request, jsonify + +logger = logging.getLogger('mcp_auth') + +_MCP_TOKEN_PREFIX = 'ib_mcp_' +_MCP_PATH_PREFIX = '/api/v1/mcp/' + + +def _utcnow(): + return datetime.now(timezone.utc) + + +def _utcnow_naive(): + """Naive UTC datetime for comparing against psycopg2 TIMESTAMP values.""" + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _hash_token(raw_token: str) -> str: + return hashlib.sha256(raw_token.encode('utf-8')).hexdigest() + + +def _reject(status, message): + return jsonify({'message': message, 'status': status}), status + + +def mcp_auth_required(f): + """Decorator that validates ib_mcp_* Bearer tokens. + + Sets on flask.g: + g.mcp_token_id – token_id (16-char prefix) + g.mcp_token_user_id – user uuid who owns the token + g.mcp_enabled_projects – dict {project_id: expires_at_iso_or_None} + g.mcp_allow_trigger – bool + """ + @wraps(f) + def decorated(*args, **kwargs): + auth = request.headers.get('Authorization', '') + + if not auth.startswith('Bearer ' + _MCP_TOKEN_PREFIX): + # not an MCP token — fall through to OPA auth (already done in before_request) + return f(*args, **kwargs) + + raw_token = auth[len('Bearer '):] + + # MCP tokens are only valid on /api/v1/mcp/* paths + if not request.path.startswith(_MCP_PATH_PREFIX): + return _reject(403, 'MCP token can only be used on /api/v1/mcp/* endpoints') + + token_suffix = raw_token[len(_MCP_TOKEN_PREFIX):] + if len(token_suffix) != 48: + return _reject(401, 'invalid MCP token format') + + token_id = token_suffix[:16] + token_hash = _hash_token(raw_token) + + row = g.db.execute_one_dict(''' + SELECT token_id, user_id, enabled_projects, allow_trigger, expires_at, revoked_at + FROM mcp_token + WHERE token_id = %s AND token_hash = %s + ''', [token_id, token_hash]) + + if not row: + return _reject(401, 'invalid or unknown MCP token') + + if row['revoked_at'] is not None: + return _reject(401, 'MCP token has been revoked') + + if row['expires_at'] < _utcnow_naive(): + return _reject(401, 'MCP token has expired') + + # Update last_used_at (best-effort, non-fatal) + try: + g.db.execute( + 'UPDATE mcp_token SET last_used_at = NOW() WHERE token_id = %s', + [token_id] + ) + g.db.commit() + except Exception as exc: + logger.warning('failed to update last_used_at: %s', exc) + + g.mcp_token_id = token_id + g.mcp_token_user_id = str(row['user_id']) + g.mcp_enabled_projects = row['enabled_projects'] or {} + g.mcp_allow_trigger = bool(row['allow_trigger']) + # Suppress OPA check for MCP-authed requests + g.token = {'type': 'mcp', 'user': {'id': str(row['user_id']), 'role': 'user'}} + + return f(*args, **kwargs) + return decorated + + +def check_project_access_mcp(project_id: str) -> bool: + """Return True if the current request may access project_id. + + MCP token path: project must be in g.mcp_enabled_projects and not past + its per-project expiry (if set). + Session path: delegates to OPA (already checked in before_request). + """ + if not hasattr(g, 'mcp_enabled_projects'): + # session / OPA path — access already verified + return True + + enabled = g.mcp_enabled_projects + if project_id not in enabled: + return False + + per_project_expiry = enabled[project_id] + if per_project_expiry: + try: + exp = datetime.fromisoformat(per_project_expiry) + if exp < _utcnow(): + return False + except (ValueError, TypeError): + pass + + return True + + +def check_trigger_access_mcp() -> bool: + """Return True if the current request may trigger builds.""" + if not hasattr(g, 'mcp_allow_trigger'): + return True + return bool(g.mcp_allow_trigger) + + +def get_mcp_user_id() -> str: + """Return user id string regardless of auth path.""" + if hasattr(g, 'mcp_token_user_id'): + return g.mcp_token_user_id + token = getattr(g, 'token', None) + if token and 'user' in token: + return str(token['user'].get('id', '')) + return request.remote_addr or 'unknown' diff --git a/src/api/handlers/mcp/rate_limit.py b/src/api/handlers/mcp/rate_limit.py new file mode 100644 index 000000000..d4a6807d9 --- /dev/null +++ b/src/api/handlers/mcp/rate_limit.py @@ -0,0 +1,99 @@ +""" +Redis sliding-window rate limiter for MCP endpoints. + +Key: ib_mcp_rl:{user_id}:{endpoint} +Window: 60 seconds +TTL: 70 seconds (slightly larger than window) + +Fail-open: if Redis is unavailable, the request is allowed and a warning is logged. +""" +import logging +import os +import uuid +from functools import wraps + +from flask import g, jsonify, request + +logger = logging.getLogger('mcp_rate_limit') + +_WINDOW_MS = 60_000 +_KEY_TTL_S = 70 + +_ENDPOINT_LIMITS = { + 'get_job_log': int(os.environ.get('MCP_RATE_LIMIT_LOG_RPM', 10)), + 'list_job_artifacts': int(os.environ.get('MCP_RATE_LIMIT_ARTIFACT_RPM', 10)), + 'list_builds': int(os.environ.get('MCP_RATE_LIMIT_BUILDS_RPM', 30)), + 'list_jobs': int(os.environ.get('MCP_RATE_LIMIT_JOBS_RPM', 30)), + 'list_projects': int(os.environ.get('MCP_RATE_LIMIT_PROJECTS_RPM', 30)), + 'trigger_build': int(os.environ.get('MCP_RATE_LIMIT_TRIGGER_RPM', 5)), +} +_DEFAULT_RPM = int(os.environ.get('MCP_RATE_LIMIT_DEFAULT_RPM', 30)) + +_redis_client = None + + +def _get_redis(): + global _redis_client + if _redis_client is not None: + return _redis_client + + redis_url = os.environ.get('REDIS_URL', '') + if not redis_url: + return None + + try: + import redis + _redis_client = redis.from_url(redis_url, socket_connect_timeout=1, socket_timeout=1) + _redis_client.ping() + except Exception as exc: + logger.warning('MCP rate limiter: Redis unavailable (%s), fail-open', exc) + _redis_client = None + + return _redis_client + + +def _check_rate_limit(user_id: str, endpoint: str) -> bool: + """Return True (allow) or False (deny). Fail-open on Redis errors.""" + rpm = _ENDPOINT_LIMITS.get(endpoint, _DEFAULT_RPM) + + try: + r = _get_redis() + if r is None: + return True + + import time + now_ms = int(time.time() * 1000) + key = f'ib_mcp_rl:{user_id}:{endpoint}' + + pipe = r.pipeline() + pipe.zremrangebyscore(key, 0, now_ms - _WINDOW_MS) + pipe.zadd(key, {str(uuid.uuid4()): now_ms}) + pipe.zcard(key) + pipe.expire(key, _KEY_TTL_S) + results = pipe.execute() + + count = results[2] + return count <= rpm + + except Exception as exc: + logger.warning('MCP rate limiter Redis error (fail-open): %s', exc) + return True + + +def mcp_rate_limit(endpoint: str): + """Decorator factory: apply rate limiting for the given endpoint name.""" + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + from api.handlers.mcp.auth import get_mcp_user_id + user_id = get_mcp_user_id() + + if not _check_rate_limit(user_id, endpoint): + return jsonify({ + 'message': 'rate limit exceeded, please slow down', + 'status': 429, + }), 429 + + return f(*args, **kwargs) + return decorated + return decorator diff --git a/src/api/handlers/mcp/routes/__init__.py b/src/api/handlers/mcp/routes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/api/handlers/mcp/routes/builds.py b/src/api/handlers/mcp/routes/builds.py new file mode 100644 index 000000000..c51648f04 --- /dev/null +++ b/src/api/handlers/mcp/routes/builds.py @@ -0,0 +1,111 @@ +""" +MCP builds endpoints. +GET /api/v1/mcp/projects//builds +POST /api/v1/mcp/projects//trigger +""" +from flask import g, jsonify, abort +from flask_restx import Resource + +from pyinfraboxutils.ibrestplus import api +from api.handlers.mcp.auth import mcp_auth_required, check_project_access_mcp, check_trigger_access_mcp +from api.handlers.mcp.rate_limit import mcp_rate_limit +from api.handlers.mcp.audit import audit_mcp + +ns = api.namespace('MCP Builds', + path='/api/v1/mcp/projects/', + description='MCP build operations') + + +@ns.route('/builds') +class MCPBuilds(Resource): + @mcp_auth_required + @mcp_rate_limit('list_builds') + def get(self, project_id): + """List builds for a project.""" + audit_mcp('list_builds', outcome='attempt', details={'project_id': project_id}) + if not check_project_access_mcp(project_id): + audit_mcp('list_builds', outcome='forbidden', details={'project_id': project_id}) + abort(403, 'access to this project is not permitted for the current MCP token') + + try: + rows = g.db.execute_many_dict(''' + SELECT id, build_number, restart_counter, project_id, commit_id, + branch, created_at, finished_at, status + FROM build + WHERE project_id = %s + ORDER BY build_number DESC, restart_counter DESC + LIMIT 50 + ''', [project_id]) + result = [_build_dict(r) for r in rows] + audit_mcp('list_builds', outcome='success', + details={'project_id': project_id, 'count': len(result)}) + return jsonify(result) + except Exception as exc: + audit_mcp('list_builds', outcome='failure', + details={'project_id': project_id}, error=str(exc)) + raise + + +@ns.route('/trigger') +class MCPTrigger(Resource): + @mcp_auth_required + @mcp_rate_limit('trigger_build') + def post(self, project_id): + """Trigger a new build (requires allow_trigger on the MCP token).""" + audit_mcp('trigger_build', outcome='attempt', details={'project_id': project_id}) + + if not check_project_access_mcp(project_id): + audit_mcp('trigger_build', outcome='forbidden', details={'project_id': project_id}) + abort(403, 'access to this project is not permitted for the current MCP token') + + if not check_trigger_access_mcp(): + audit_mcp('trigger_build', outcome='forbidden', + details={'project_id': project_id, 'reason': 'trigger not allowed'}) + abort(403, 'this MCP token does not have trigger permission') + + # Delegate to the existing /api/v1/projects//trigger endpoint internals. + # We do this by calling the DB-level trigger path that creates a build entry + # for manual (upload-type) triggers. Full GitHub/gerrit triggers are handled + # by the existing endpoint and are out of scope for MCP. + project = g.db.execute_one_dict(''' + SELECT id, type FROM project WHERE id = %s + ''', [project_id]) + if not project: + abort(404) + if project['type'] not in ('upload', 'test'): + return jsonify({ + 'message': 'trigger is only supported for upload/test type projects via MCP', + 'status': 400, + }), 400 + + try: + import uuid as _uuid + build_id = str(_uuid.uuid4()) + # Create a minimal build record; the scheduler picks it up from here. + g.db.execute(''' + INSERT INTO build (id, project_id, build_number, restart_counter, source) + SELECT %s, %s, COALESCE(MAX(build_number), 0) + 1, 0, 'mcp' + FROM build WHERE project_id = %s + ''', [build_id, project_id, project_id]) + g.db.commit() + audit_mcp('trigger_build', outcome='success', + details={'project_id': project_id, 'build_id': build_id}) + return jsonify({'build_id': build_id, 'status': 200}), 200 + except Exception as exc: + audit_mcp('trigger_build', outcome='failure', + details={'project_id': project_id}, error=str(exc)) + raise + + +def _build_dict(r): + return { + 'id': r['id'], + 'build_number': r['build_number'], + 'restart_counter': r['restart_counter'], + 'project_id': r['project_id'], + 'commit_id': r.get('commit_id'), + 'branch': r.get('branch'), + 'created_at': r['created_at'].isoformat() if r.get('created_at') else None, + 'finished_at': r['finished_at'].isoformat() if r.get('finished_at') else None, + 'status': r.get('status'), + } diff --git a/src/api/handlers/mcp/routes/jobs.py b/src/api/handlers/mcp/routes/jobs.py new file mode 100644 index 000000000..1e12b40c6 --- /dev/null +++ b/src/api/handlers/mcp/routes/jobs.py @@ -0,0 +1,137 @@ +""" +MCP job endpoints. +GET /api/v1/mcp/projects//builds//jobs +GET /api/v1/mcp/projects//jobs//log +GET /api/v1/mcp/projects//jobs//artifacts +""" +from flask import g, jsonify, abort +from flask_restx import Resource + +from pyinfraboxutils.ibrestplus import api +from api.handlers.mcp.auth import mcp_auth_required, check_project_access_mcp +from api.handlers.mcp.rate_limit import mcp_rate_limit +from api.handlers.mcp.audit import audit_mcp + +ns_build_jobs = api.namespace('MCP Build Jobs', + path='/api/v1/mcp/projects//builds/', + description='MCP job list') + +ns_job = api.namespace('MCP Jobs', + path='/api/v1/mcp/projects//jobs/', + description='MCP individual job operations') + + +@ns_build_jobs.route('/jobs') +class MCPJobList(Resource): + @mcp_auth_required + @mcp_rate_limit('list_jobs') + def get(self, project_id, build_id): + """List jobs for a build.""" + audit_mcp('list_jobs', outcome='attempt', + details={'project_id': project_id, 'build_id': build_id}) + if not check_project_access_mcp(project_id): + audit_mcp('list_jobs', outcome='forbidden', details={'project_id': project_id}) + abort(403, 'access to this project is not permitted for the current MCP token') + + try: + rows = g.db.execute_many_dict(''' + SELECT j.id, j.name, j.state, j.build_id, j.project_id, + j.start_date, j.end_date, j.cpu, j.memory, j.message + FROM job j + WHERE j.build_id = %s AND j.project_id = %s + ORDER BY j.name + ''', [build_id, project_id]) + result = [_job_dict(r) for r in rows] + audit_mcp('list_jobs', outcome='success', + details={'project_id': project_id, 'build_id': build_id, 'count': len(result)}) + return jsonify(result) + except Exception as exc: + audit_mcp('list_jobs', outcome='failure', + details={'project_id': project_id, 'build_id': build_id}, error=str(exc)) + raise + + +@ns_job.route('/log') +class MCPJobLog(Resource): + @mcp_auth_required + @mcp_rate_limit('get_job_log') + def get(self, project_id, job_id): + """Get console log for a job.""" + audit_mcp('get_job_log', outcome='attempt', + details={'project_id': project_id, 'job_id': job_id}) + if not check_project_access_mcp(project_id): + audit_mcp('get_job_log', outcome='forbidden', details={'project_id': project_id}) + abort(403, 'access to this project is not permitted for the current MCP token') + + # Verify job belongs to project + job = g.db.execute_one_dict(''' + SELECT id FROM job WHERE id = %s AND project_id = %s + ''', [job_id, project_id]) + if not job: + abort(404) + + try: + rows = g.db.execute_many_dict(''' + SELECT output FROM console WHERE job_id = %s ORDER BY date + ''', [job_id]) + log = ''.join(r['output'] for r in rows) + audit_mcp('get_job_log', outcome='success', + details={'project_id': project_id, 'job_id': job_id}) + return log, 200, {'Content-Type': 'text/plain; charset=utf-8'} + except Exception as exc: + audit_mcp('get_job_log', outcome='failure', + details={'project_id': project_id, 'job_id': job_id}, error=str(exc)) + raise + + +@ns_job.route('/artifacts') +class MCPJobArtifacts(Resource): + @mcp_auth_required + @mcp_rate_limit('list_job_artifacts') + def get(self, project_id, job_id): + """List artifacts for a job.""" + audit_mcp('list_job_artifacts', outcome='attempt', + details={'project_id': project_id, 'job_id': job_id}) + if not check_project_access_mcp(project_id): + audit_mcp('list_job_artifacts', outcome='forbidden', details={'project_id': project_id}) + abort(403, 'access to this project is not permitted for the current MCP token') + + job = g.db.execute_one_dict(''' + SELECT id FROM job WHERE id = %s AND project_id = %s + ''', [job_id, project_id]) + if not job: + abort(404) + + try: + rows = g.db.execute_many_dict(''' + SELECT filename, filesize, created_at + FROM archive + WHERE job_id = %s + ORDER BY filename + ''', [job_id]) + result = [{'filename': r['filename'], + 'filesize': r.get('filesize'), + 'created_at': r['created_at'].isoformat() if r.get('created_at') else None} + for r in rows] + audit_mcp('list_job_artifacts', outcome='success', + details={'project_id': project_id, 'job_id': job_id, 'count': len(result)}) + return jsonify(result) + except Exception as exc: + audit_mcp('list_job_artifacts', outcome='failure', + details={'project_id': project_id, 'job_id': job_id}, error=str(exc)) + raise + + +def _job_dict(r): + return { + 'id': r['id'], + 'name': r['name'], + 'state': r['state'], + 'build_id': r['build_id'], + 'project_id': r['project_id'], + 'start_date': r['start_date'].isoformat() if r.get('start_date') else None, + 'end_date': r['end_date'].isoformat() if r.get('end_date') else None, + 'cpu': r.get('cpu'), + 'memory': r.get('memory'), + 'message': r.get('message'), + } diff --git a/src/api/handlers/mcp/routes/projects.py b/src/api/handlers/mcp/routes/projects.py new file mode 100644 index 000000000..80908501f --- /dev/null +++ b/src/api/handlers/mcp/routes/projects.py @@ -0,0 +1,57 @@ +""" +MCP projects endpoint: GET /api/v1/mcp/projects +Returns projects the MCP token has access to. +""" +from flask import g, jsonify +from flask_restx import Resource + +from pyinfraboxutils.ibrestplus import api +from api.handlers.mcp.auth import mcp_auth_required, get_mcp_user_id +from api.handlers.mcp.rate_limit import mcp_rate_limit +from api.handlers.mcp.audit import audit_mcp + +ns = api.namespace('MCP Projects', + path='/api/v1/mcp', + description='MCP project operations') + + +@ns.route('/projects') +class MCPProjects(Resource): + @mcp_auth_required + @mcp_rate_limit('list_projects') + def get(self): + """List projects accessible to the current MCP token or session user.""" + audit_mcp('list_projects', outcome='attempt') + try: + user_id = get_mcp_user_id() + enabled = getattr(g, 'mcp_enabled_projects', None) + + if enabled is not None: + # MCP token path: return only scoped projects + if not enabled: + audit_mcp('list_projects', outcome='success', details={'count': 0}) + return jsonify([]) + + project_ids = list(enabled.keys()) + rows = g.db.execute_many_dict(''' + SELECT id, name, type, public + FROM project + WHERE id = ANY(%s::uuid[]) + ORDER BY name + ''', [project_ids]) + else: + # Session path: return all projects the user is a collaborator on + rows = g.db.execute_many_dict(''' + SELECT p.id, p.name, p.type, p.public + FROM project p + INNER JOIN collaborator co ON co.project_id = p.id AND co.user_id = %s + ORDER BY p.name + ''', [user_id]) + + result = [{'id': r['id'], 'name': r['name'], 'type': r['type'], 'public': r['public']} + for r in rows] + audit_mcp('list_projects', outcome='success', details={'count': len(result)}) + return jsonify(result) + except Exception as exc: + audit_mcp('list_projects', outcome='failure', error=str(exc)) + raise diff --git a/src/api/handlers/mcp/token_routes.py b/src/api/handlers/mcp/token_routes.py new file mode 100644 index 000000000..acb4ba363 --- /dev/null +++ b/src/api/handlers/mcp/token_routes.py @@ -0,0 +1,217 @@ +""" +MCP token management endpoints. +Authenticated by normal session/OPA auth (not MCP tokens). + +POST /api/v1/mcp/tokens create +GET /api/v1/mcp/tokens list +PATCH /api/v1/mcp/tokens/ update +DELETE /api/v1/mcp/tokens/ revoke +POST /api/v1/mcp/tokens//trigger grant trigger permission (time-limited, hours) +DELETE /api/v1/mcp/tokens//trigger revoke trigger permission +""" +import hashlib +import os +import secrets +from datetime import datetime, timezone, timedelta + +from flask import g, jsonify, request, abort +from flask_restx import Resource + +from pyinfraboxutils.ibrestplus import api +from api.handlers.mcp.audit import audit_mcp + +_MAX_EXPIRY_DAYS = int(os.environ.get('MCP_TOKEN_MAX_EXPIRY_DAYS', 365)) +_MAX_PER_USER = int(os.environ.get('MCP_TOKEN_MAX_PER_USER', 20)) + +ns = api.namespace('MCP Tokens', + path='/api/v1/mcp/tokens', + description='MCP token management (session auth required)') + + +def _utcnow(): + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _current_user_id(): + token = getattr(g, 'token', None) + if not token or 'user' not in token: + abort(401, 'authentication required') + return str(token['user']['id']) + + +def _own_token(token_id, user_id): + return g.db.execute_one_dict(''' + SELECT token_id, name, enabled_projects, allow_trigger, expires_at, revoked_at, created_at, last_used_at + FROM mcp_token + WHERE token_id = %s AND user_id = %s AND revoked_at IS NULL + ''', [token_id, user_id]) + + +@ns.route('/') +class MCPTokenList(Resource): + def get(self): + """List all active MCP tokens for the current user.""" + user_id = _current_user_id() + rows = g.db.execute_many_dict(''' + SELECT token_id, name, enabled_projects, allow_trigger, expires_at, created_at, last_used_at + FROM mcp_token + WHERE user_id = %s AND revoked_at IS NULL + ORDER BY created_at DESC + ''', [user_id]) + return jsonify([_serialize(r) for r in rows]) + + def post(self): + """Create a new MCP token.""" + user_id = _current_user_id() + + count = g.db.execute_one(''' + SELECT COUNT(*) FROM mcp_token WHERE user_id = %s AND revoked_at IS NULL + ''', [user_id])[0] + if count >= _MAX_PER_USER: + return jsonify({'message': f'max {_MAX_PER_USER} tokens per user', 'status': 400}), 400 + + body = request.get_json(silent=True) or {} + name = (body.get('name') or '').strip()[:128] + if not name: + return jsonify({'message': 'name is required', 'status': 400}), 400 + + enabled_projects = body.get('enabled_projects') or {} + if not isinstance(enabled_projects, dict): + return jsonify({'message': 'enabled_projects must be an object', 'status': 400}), 400 + + expires_days = int(body.get('expires_days') or _MAX_EXPIRY_DAYS) + if expires_days < 1 or expires_days > _MAX_EXPIRY_DAYS: + return jsonify({'message': f'expires_days must be 1–{_MAX_EXPIRY_DAYS}', 'status': 400}), 400 + + expires_at = _utcnow() + timedelta(days=expires_days) + + raw_suffix = secrets.token_hex(24) # 48 hex chars + raw_token = f'ib_mcp_{raw_suffix}' + token_id = raw_suffix[:16] + token_hash = hashlib.sha256(raw_token.encode('utf-8')).hexdigest() + + import json + g.db.execute(''' + INSERT INTO mcp_token (token_id, token_hash, user_id, name, enabled_projects, allow_trigger, expires_at) + VALUES (%s, %s, %s, %s, %s, FALSE, %s) + ''', [token_id, token_hash, user_id, name, json.dumps(enabled_projects), expires_at]) + g.db.commit() + + audit_mcp('create_mcp_token', outcome='success', details={'token_id': token_id, 'name': name}) + + return jsonify({ + 'token_id': token_id, + 'token': raw_token, # shown once only + 'name': name, + 'enabled_projects': enabled_projects, + 'allow_trigger': False, + 'expires_at': expires_at.isoformat(), + }), 201 + + +@ns.route('/') +class MCPToken(Resource): + def patch(self, token_id): + """Update a token's name, enabled_projects, or expires_at.""" + user_id = _current_user_id() + row = _own_token(token_id, user_id) + if not row: + abort(404) + + body = request.get_json(silent=True) or {} + + updates = [] + params = [] + + if 'name' in body: + name = str(body['name']).strip()[:128] + updates.append('name = %s') + params.append(name) + + if 'enabled_projects' in body: + if not isinstance(body['enabled_projects'], dict): + return jsonify({'message': 'enabled_projects must be an object', 'status': 400}), 400 + import json + updates.append('enabled_projects = %s') + params.append(json.dumps(body['enabled_projects'])) + + if 'expires_days' in body: + days = int(body['expires_days']) + if days < 1 or days > _MAX_EXPIRY_DAYS: + return jsonify({'message': f'expires_days must be 1–{_MAX_EXPIRY_DAYS}', 'status': 400}), 400 + updates.append('expires_at = %s') + params.append(_utcnow() + timedelta(days=days)) + + if not updates: + return jsonify({'message': 'nothing to update', 'status': 400}), 400 + + params.append(token_id) + params.append(user_id) + g.db.execute( + f'UPDATE mcp_token SET {", ".join(updates)} WHERE token_id = %s AND user_id = %s', + params + ) + g.db.commit() + audit_mcp('update_mcp_token', outcome='success', details={'token_id': token_id}) + return jsonify({'message': 'token updated', 'status': 200}) + + def delete(self, token_id): + """Revoke a token (soft delete).""" + user_id = _current_user_id() + row = _own_token(token_id, user_id) + if not row: + abort(404) + + g.db.execute( + 'UPDATE mcp_token SET revoked_at = NOW() WHERE token_id = %s AND user_id = %s', + [token_id, user_id] + ) + g.db.commit() + audit_mcp('revoke_mcp_token', outcome='success', details={'token_id': token_id}) + return jsonify({'message': 'token revoked', 'status': 200}) + + +@ns.route('//trigger') +class MCPTokenTrigger(Resource): + def post(self, token_id): + """Grant trigger permission to a token (time-limited by hours, max 168).""" + user_id = _current_user_id() + row = _own_token(token_id, user_id) + if not row: + abort(404) + + body = request.get_json(silent=True) or {} + g.db.execute( + 'UPDATE mcp_token SET allow_trigger = TRUE WHERE token_id = %s AND user_id = %s', + [token_id, user_id] + ) + g.db.commit() + audit_mcp('grant_mcp_trigger', outcome='success', details={'token_id': token_id}) + return jsonify({'message': 'trigger permission granted', 'status': 200}) + + def delete(self, token_id): + """Revoke trigger permission from a token.""" + user_id = _current_user_id() + row = _own_token(token_id, user_id) + if not row: + abort(404) + + g.db.execute( + 'UPDATE mcp_token SET allow_trigger = FALSE WHERE token_id = %s AND user_id = %s', + [token_id, user_id] + ) + g.db.commit() + audit_mcp('revoke_mcp_trigger', outcome='success', details={'token_id': token_id}) + return jsonify({'message': 'trigger permission revoked', 'status': 200}) + + +def _serialize(row): + return { + 'token_id': row['token_id'], + 'name': row['name'], + 'enabled_projects': row['enabled_projects'], + 'allow_trigger': row['allow_trigger'], + 'expires_at': row['expires_at'].isoformat() if row['expires_at'] else None, + 'created_at': row['created_at'].isoformat() if row['created_at'] else None, + 'last_used_at': row['last_used_at'].isoformat() if row.get('last_used_at') else None, + } diff --git a/src/dashboard-client/build.sh b/src/dashboard-client/build.sh index 646f7dc61..27b8245d5 100755 --- a/src/dashboard-client/build.sh +++ b/src/dashboard-client/build.sh @@ -2,8 +2,11 @@ cp -r /infrabox/context/src/dashboard-client /dashboard echo "## Link cache" -mkdir -p /infrabox/cache/node_modules -cp -r /infrabox/cache/node_modules /dashboard +if [ -d /infrabox/cache/node_modules ]; then + mv /infrabox/cache/node_modules /dashboard/node_modules +else + mkdir -p /dashboard/node_modules +fi cd /dashboard @@ -15,11 +18,7 @@ npm install echo "## build" npm run build -echo "## Copy to cache" -rm -rf /infrabox/cache/node_modules -cp -r /dashboard/node_modules /infrabox/cache - echo "## Copy to output" cp -r /dashboard/dist /infrabox/output -echo "## done" +echo "## done" \ No newline at end of file diff --git a/src/dashboard-client/build/build.js b/src/dashboard-client/build/build.js index 30f036a18..ac27aeef1 100644 --- a/src/dashboard-client/build/build.js +++ b/src/dashboard-client/build/build.js @@ -37,5 +37,6 @@ rm(path.join(config.build.assetsRoot, config.build.assetsSubDirectory), err => { ' Tip: built files are meant to be served over an HTTP server.\n' + ' Opening index.html over file:// won\'t work.\n' )) + process.exit(0) }) }) diff --git a/src/db/migrations/00046.sql b/src/db/migrations/00046.sql new file mode 100644 index 000000000..7b72efcdc --- /dev/null +++ b/src/db/migrations/00046.sql @@ -0,0 +1,33 @@ +CREATE TABLE mcp_token ( + token_id VARCHAR(16) NOT NULL, + token_hash VARCHAR(64) NOT NULL, + user_id uuid NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + name VARCHAR(128) NOT NULL, + enabled_projects JSONB NOT NULL DEFAULT '{}', + allow_trigger BOOLEAN NOT NULL DEFAULT FALSE, + 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_mcp_token_user_id ON mcp_token(user_id); +CREATE INDEX idx_mcp_token_hash ON mcp_token(token_hash); + +-- Audit log for all MCP API calls +CREATE TABLE mcp_access_log ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + token_id VARCHAR(16), + user_id uuid, + action VARCHAR(128) NOT NULL, + outcome VARCHAR(32) NOT NULL, + details JSONB, + error TEXT, + ip VARCHAR(64), + accessed_at TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY (id) +); + +CREATE INDEX idx_mcp_access_log_token_id ON mcp_access_log(token_id); +CREATE INDEX idx_mcp_access_log_accessed_at ON mcp_access_log(accessed_at);