codecortex 0.4.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of codecortex might be problematic. Click here for more details.
- {codecortex-0.4.0/src/codecortex.egg-info → codecortex-0.5.0}/PKG-INFO +5 -3
- {codecortex-0.4.0 → codecortex-0.5.0}/README.md +4 -2
- {codecortex-0.4.0 → codecortex-0.5.0/src/codecortex.egg-info}/PKG-INFO +5 -3
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codecortex.egg-info/SOURCES.txt +2 -0
- codecortex-0.5.0/src/codeintel/__init__.py +1 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/__main__.py +7 -0
- codecortex-0.5.0/src/codeintel/auth.py +120 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/gateway.py +22 -9
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/http_server.py +46 -26
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/server.py +18 -1
- codecortex-0.5.0/tests/test_rbac.py +169 -0
- codecortex-0.4.0/src/codeintel/__init__.py +0 -1
- {codecortex-0.4.0 → codecortex-0.5.0}/LICENSE +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/pyproject.toml +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/setup.cfg +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codecortex.egg-info/dependency_links.txt +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codecortex.egg-info/entry_points.txt +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codecortex.egg-info/requires.txt +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codecortex.egg-info/top_level.txt +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/cache.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/config.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/doctor.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/indexer.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/injector.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/installer.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/logconfig.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/mapper.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/metrics.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/onboarding.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/policy.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/provider.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/providers/__init__.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/providers/graph.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/providers/lsp.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/providers/none.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/providers/semantic.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/reindexer.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/reset.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/searcher.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/semantic_db.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/term.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_cache.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_config.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_doctor.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_e2e.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_enterprise.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_gateway.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_graph_provider.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_graph_real.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_graph_stdin.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_hardening.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_http_auth.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_http_server.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_integration.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_lsp_provider.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_lsp_real.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_mapper.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_never_raise.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_onboarding.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_reindexer.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_reset.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_semantic_provider.py +0 -0
- {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_term.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecortex
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
|
|
5
5
|
Author: Shammai Hamilton
|
|
6
6
|
License-Expression: MIT
|
|
@@ -189,6 +189,7 @@ Full system docs live in [`docs/`](docs/) — start with the index:
|
|
|
189
189
|
| `codeintel doctor [project_root] [--deep] [--json]` | Diagnose per-engine health + repo index status, with a fix for each gap |
|
|
190
190
|
| `codeintel map [project_root]` | Generate the `CODE_INTEL.md` orientation file |
|
|
191
191
|
| `codeintel reset [project_root] [--all] [--yes]` | Clear the semantic index (this repo, or `--all`) to recover from a corrupt/stale DB |
|
|
192
|
+
| `codeintel gen-token` | Print a secure random bearer token (for `serve-http` / RBAC `auth.toml`) |
|
|
192
193
|
|
|
193
194
|
Human-facing commands (`doctor`, `status`, `query`, `setup`, `reset`) honor `--no-color` / `NO_COLOR` and `--ascii`, and auto-degrade to plain text when piped.
|
|
194
195
|
|
|
@@ -213,6 +214,7 @@ Config is **validated on load** — an out-of-range number, a misspelled enum, o
|
|
|
213
214
|
| Variable | Effect |
|
|
214
215
|
|---|---|
|
|
215
216
|
| `CODEINTEL_HTTP_TOKEN` | Bearer token required by `serve-http` (equivalent to `--token`) |
|
|
217
|
+
| `CODEINTEL_AUTH_CONFIG` | Path to an RBAC token→role config (default `~/.codeintel/auth.toml`) — per-token roles + op scopes |
|
|
216
218
|
| `CODEINTEL_LOG_LEVEL` | `DEBUG`\|`INFO`\|`WARNING`(default)\|`ERROR` for the server logger |
|
|
217
219
|
| `CODEINTEL_LOG_FORMAT=json` | Structured (JSON-per-line) logs for ELK / Splunk / Datadog |
|
|
218
220
|
| `CODEINTEL_HTTP_ACCESS_LOG=1` | One log line per HTTP request (method, path, status, latency) |
|
|
@@ -286,9 +288,9 @@ Running codeintel as a shared service? It ships with what ops teams expect:
|
|
|
286
288
|
| `GET /readyz` | none | Readiness — `200` once the gateway is up (`readinessProbe`) |
|
|
287
289
|
| `GET /metrics` | token | Prometheus exposition — request counts, latency, in-flight, build info |
|
|
288
290
|
|
|
289
|
-
Plus **bearer-token auth
|
|
291
|
+
Plus **bearer-token auth** — or **RBAC** (per-token roles + op scopes via `auth.toml`; a disallowed op returns `403`, and the role is server-authoritative so a client can't escalate) — **structured JSON logs** (`CODEINTEL_LOG_FORMAT=json`) with optional per-request access logs, **graceful `SIGTERM`** shutdown, a bounded connection pool, and a non-root **Dockerfile** with a healthcheck.
|
|
290
292
|
|
|
291
|
-
**Full guide → [docs/deploy.md](docs/deploy.md)**: systemd, Docker / Compose, Kubernetes (liveness + readiness probes, token from a Secret), reverse-proxy TLS, a Prometheus scrape config, and a security checklist.
|
|
293
|
+
**Full guide → [docs/deploy.md](docs/deploy.md)**: systemd, Docker / Compose, Kubernetes (liveness + readiness probes, token from a Secret), reverse-proxy TLS, **RBAC + SSO-via-auth-proxy**, a Prometheus scrape config, and a security checklist.
|
|
292
294
|
|
|
293
295
|
```bash
|
|
294
296
|
docker build -t codeintel . && docker run -p 127.0.0.1:8766:8766 \
|
|
@@ -156,6 +156,7 @@ Full system docs live in [`docs/`](docs/) — start with the index:
|
|
|
156
156
|
| `codeintel doctor [project_root] [--deep] [--json]` | Diagnose per-engine health + repo index status, with a fix for each gap |
|
|
157
157
|
| `codeintel map [project_root]` | Generate the `CODE_INTEL.md` orientation file |
|
|
158
158
|
| `codeintel reset [project_root] [--all] [--yes]` | Clear the semantic index (this repo, or `--all`) to recover from a corrupt/stale DB |
|
|
159
|
+
| `codeintel gen-token` | Print a secure random bearer token (for `serve-http` / RBAC `auth.toml`) |
|
|
159
160
|
|
|
160
161
|
Human-facing commands (`doctor`, `status`, `query`, `setup`, `reset`) honor `--no-color` / `NO_COLOR` and `--ascii`, and auto-degrade to plain text when piped.
|
|
161
162
|
|
|
@@ -180,6 +181,7 @@ Config is **validated on load** — an out-of-range number, a misspelled enum, o
|
|
|
180
181
|
| Variable | Effect |
|
|
181
182
|
|---|---|
|
|
182
183
|
| `CODEINTEL_HTTP_TOKEN` | Bearer token required by `serve-http` (equivalent to `--token`) |
|
|
184
|
+
| `CODEINTEL_AUTH_CONFIG` | Path to an RBAC token→role config (default `~/.codeintel/auth.toml`) — per-token roles + op scopes |
|
|
183
185
|
| `CODEINTEL_LOG_LEVEL` | `DEBUG`\|`INFO`\|`WARNING`(default)\|`ERROR` for the server logger |
|
|
184
186
|
| `CODEINTEL_LOG_FORMAT=json` | Structured (JSON-per-line) logs for ELK / Splunk / Datadog |
|
|
185
187
|
| `CODEINTEL_HTTP_ACCESS_LOG=1` | One log line per HTTP request (method, path, status, latency) |
|
|
@@ -253,9 +255,9 @@ Running codeintel as a shared service? It ships with what ops teams expect:
|
|
|
253
255
|
| `GET /readyz` | none | Readiness — `200` once the gateway is up (`readinessProbe`) |
|
|
254
256
|
| `GET /metrics` | token | Prometheus exposition — request counts, latency, in-flight, build info |
|
|
255
257
|
|
|
256
|
-
Plus **bearer-token auth
|
|
258
|
+
Plus **bearer-token auth** — or **RBAC** (per-token roles + op scopes via `auth.toml`; a disallowed op returns `403`, and the role is server-authoritative so a client can't escalate) — **structured JSON logs** (`CODEINTEL_LOG_FORMAT=json`) with optional per-request access logs, **graceful `SIGTERM`** shutdown, a bounded connection pool, and a non-root **Dockerfile** with a healthcheck.
|
|
257
259
|
|
|
258
|
-
**Full guide → [docs/deploy.md](docs/deploy.md)**: systemd, Docker / Compose, Kubernetes (liveness + readiness probes, token from a Secret), reverse-proxy TLS, a Prometheus scrape config, and a security checklist.
|
|
260
|
+
**Full guide → [docs/deploy.md](docs/deploy.md)**: systemd, Docker / Compose, Kubernetes (liveness + readiness probes, token from a Secret), reverse-proxy TLS, **RBAC + SSO-via-auth-proxy**, a Prometheus scrape config, and a security checklist.
|
|
259
261
|
|
|
260
262
|
```bash
|
|
261
263
|
docker build -t codeintel . && docker run -p 127.0.0.1:8766:8766 \
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecortex
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
|
|
5
5
|
Author: Shammai Hamilton
|
|
6
6
|
License-Expression: MIT
|
|
@@ -189,6 +189,7 @@ Full system docs live in [`docs/`](docs/) — start with the index:
|
|
|
189
189
|
| `codeintel doctor [project_root] [--deep] [--json]` | Diagnose per-engine health + repo index status, with a fix for each gap |
|
|
190
190
|
| `codeintel map [project_root]` | Generate the `CODE_INTEL.md` orientation file |
|
|
191
191
|
| `codeintel reset [project_root] [--all] [--yes]` | Clear the semantic index (this repo, or `--all`) to recover from a corrupt/stale DB |
|
|
192
|
+
| `codeintel gen-token` | Print a secure random bearer token (for `serve-http` / RBAC `auth.toml`) |
|
|
192
193
|
|
|
193
194
|
Human-facing commands (`doctor`, `status`, `query`, `setup`, `reset`) honor `--no-color` / `NO_COLOR` and `--ascii`, and auto-degrade to plain text when piped.
|
|
194
195
|
|
|
@@ -213,6 +214,7 @@ Config is **validated on load** — an out-of-range number, a misspelled enum, o
|
|
|
213
214
|
| Variable | Effect |
|
|
214
215
|
|---|---|
|
|
215
216
|
| `CODEINTEL_HTTP_TOKEN` | Bearer token required by `serve-http` (equivalent to `--token`) |
|
|
217
|
+
| `CODEINTEL_AUTH_CONFIG` | Path to an RBAC token→role config (default `~/.codeintel/auth.toml`) — per-token roles + op scopes |
|
|
216
218
|
| `CODEINTEL_LOG_LEVEL` | `DEBUG`\|`INFO`\|`WARNING`(default)\|`ERROR` for the server logger |
|
|
217
219
|
| `CODEINTEL_LOG_FORMAT=json` | Structured (JSON-per-line) logs for ELK / Splunk / Datadog |
|
|
218
220
|
| `CODEINTEL_HTTP_ACCESS_LOG=1` | One log line per HTTP request (method, path, status, latency) |
|
|
@@ -286,9 +288,9 @@ Running codeintel as a shared service? It ships with what ops teams expect:
|
|
|
286
288
|
| `GET /readyz` | none | Readiness — `200` once the gateway is up (`readinessProbe`) |
|
|
287
289
|
| `GET /metrics` | token | Prometheus exposition — request counts, latency, in-flight, build info |
|
|
288
290
|
|
|
289
|
-
Plus **bearer-token auth
|
|
291
|
+
Plus **bearer-token auth** — or **RBAC** (per-token roles + op scopes via `auth.toml`; a disallowed op returns `403`, and the role is server-authoritative so a client can't escalate) — **structured JSON logs** (`CODEINTEL_LOG_FORMAT=json`) with optional per-request access logs, **graceful `SIGTERM`** shutdown, a bounded connection pool, and a non-root **Dockerfile** with a healthcheck.
|
|
290
292
|
|
|
291
|
-
**Full guide → [docs/deploy.md](docs/deploy.md)**: systemd, Docker / Compose, Kubernetes (liveness + readiness probes, token from a Secret), reverse-proxy TLS, a Prometheus scrape config, and a security checklist.
|
|
293
|
+
**Full guide → [docs/deploy.md](docs/deploy.md)**: systemd, Docker / Compose, Kubernetes (liveness + readiness probes, token from a Secret), reverse-proxy TLS, **RBAC + SSO-via-auth-proxy**, a Prometheus scrape config, and a security checklist.
|
|
292
294
|
|
|
293
295
|
```bash
|
|
294
296
|
docker build -t codeintel . && docker run -p 127.0.0.1:8766:8766 \
|
|
@@ -9,6 +9,7 @@ src/codecortex.egg-info/requires.txt
|
|
|
9
9
|
src/codecortex.egg-info/top_level.txt
|
|
10
10
|
src/codeintel/__init__.py
|
|
11
11
|
src/codeintel/__main__.py
|
|
12
|
+
src/codeintel/auth.py
|
|
12
13
|
src/codeintel/cache.py
|
|
13
14
|
src/codeintel/config.py
|
|
14
15
|
src/codeintel/doctor.py
|
|
@@ -52,6 +53,7 @@ tests/test_lsp_real.py
|
|
|
52
53
|
tests/test_mapper.py
|
|
53
54
|
tests/test_never_raise.py
|
|
54
55
|
tests/test_onboarding.py
|
|
56
|
+
tests/test_rbac.py
|
|
55
57
|
tests/test_reindexer.py
|
|
56
58
|
tests/test_reset.py
|
|
57
59
|
tests/test_semantic_provider.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.0"
|
|
@@ -90,6 +90,8 @@ def main() -> None:
|
|
|
90
90
|
reset_parser.add_argument("--yes", "-y", action="store_true", help="Skip the confirmation prompt")
|
|
91
91
|
reset_parser.add_argument("--json", action="store_true", help="Emit the structured JSON report")
|
|
92
92
|
|
|
93
|
+
subparsers.add_parser("gen-token", help="Print a secure random bearer token (for serve-http / RBAC auth.toml)")
|
|
94
|
+
|
|
93
95
|
args = parser.parse_args()
|
|
94
96
|
|
|
95
97
|
from codeintel import term
|
|
@@ -355,6 +357,11 @@ def main() -> None:
|
|
|
355
357
|
|
|
356
358
|
sys.exit(0 if any_ok else 1)
|
|
357
359
|
|
|
360
|
+
elif args.command == "gen-token":
|
|
361
|
+
import secrets
|
|
362
|
+
print(secrets.token_urlsafe(32))
|
|
363
|
+
sys.exit(0)
|
|
364
|
+
|
|
358
365
|
else:
|
|
359
366
|
parser.print_help()
|
|
360
367
|
sys.exit(0)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Token→role authentication and RBAC for the HTTP transport.
|
|
2
|
+
|
|
3
|
+
An optional auth config (``~/.codeintel/auth.toml`` or ``$CODEINTEL_AUTH_CONFIG``) maps bearer
|
|
4
|
+
tokens to roles and roles to the operations they may run. The role is derived **server-side from
|
|
5
|
+
the authenticated token** — a client can never escalate by claiming a role in the request body.
|
|
6
|
+
|
|
7
|
+
Example ``auth.toml``::
|
|
8
|
+
|
|
9
|
+
[roles]
|
|
10
|
+
admin = ["*"] # ["*"] (or omitted) = every op
|
|
11
|
+
reader = ["search", "symbol", "overview", "callers", "callees", "impact", "chain", "context"]
|
|
12
|
+
searcher = ["search", "context"]
|
|
13
|
+
|
|
14
|
+
[tokens] # token = role (store this file mode 0600)
|
|
15
|
+
"s3cret-admin-value" = "admin"
|
|
16
|
+
"sha256:<hex-of-a-token>" = "reader" # pre-hashed form, so plaintext need not be at rest
|
|
17
|
+
|
|
18
|
+
SSO: put an OIDC auth proxy (e.g. oauth2-proxy) in front and have it present a per-role bearer
|
|
19
|
+
token to codeintel — codeintel never speaks to the IdP itself (local-first, no per-query network).
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
import pathlib
|
|
27
|
+
import tomllib # stdlib on Python 3.11+ (the project's minimum)
|
|
28
|
+
from typing import Dict, List, Optional
|
|
29
|
+
|
|
30
|
+
from codeintel.policy import TieringPolicy
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("codeintel")
|
|
33
|
+
|
|
34
|
+
_ALL = "*"
|
|
35
|
+
_SHA_PREFIX = "sha256:"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TokenAuth:
|
|
39
|
+
"""Resolves a bearer token to a role and builds the matching op policy. Tokens are held as
|
|
40
|
+
sha256 hashes; a presented token is hashed and looked up in O(1). Immutable; thread-safe."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, token_hash_to_role: Dict[str, str], role_ops: Dict[str, List[str]]) -> None:
|
|
43
|
+
self._tokens = token_hash_to_role
|
|
44
|
+
self._role_ops = role_ops
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def enabled(self) -> bool:
|
|
48
|
+
return bool(self._tokens)
|
|
49
|
+
|
|
50
|
+
def role_for(self, presented_token: str) -> Optional[str]:
|
|
51
|
+
"""The role for a presented token, or None if it isn't a known token."""
|
|
52
|
+
if not presented_token:
|
|
53
|
+
return None
|
|
54
|
+
h = hashlib.sha256(presented_token.encode("utf-8")).hexdigest()
|
|
55
|
+
return self._tokens.get(h)
|
|
56
|
+
|
|
57
|
+
def build_policy(self) -> TieringPolicy:
|
|
58
|
+
"""A role with ops ``["*"]`` (or none) is unrestricted → omit it from the rules, since
|
|
59
|
+
TieringPolicy treats a role absent from its rules as full-access. Every other role maps to
|
|
60
|
+
its explicit op allowlist (an empty list = deny all ops, a fail-safe default)."""
|
|
61
|
+
rules: Dict[str, List[str]] = {}
|
|
62
|
+
for role, ops in self._role_ops.items():
|
|
63
|
+
if _ALL in ops:
|
|
64
|
+
continue # unrestricted
|
|
65
|
+
rules[role] = list(ops)
|
|
66
|
+
return TieringPolicy(enabled=bool(rules), rules=rules)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _auth_config_path() -> Optional[pathlib.Path]:
|
|
70
|
+
env = os.environ.get("CODEINTEL_AUTH_CONFIG", "").strip()
|
|
71
|
+
if env:
|
|
72
|
+
p = pathlib.Path(env)
|
|
73
|
+
return p if p.is_file() else None
|
|
74
|
+
default = pathlib.Path.home() / ".codeintel" / "auth.toml"
|
|
75
|
+
return default if default.is_file() else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_auth() -> TokenAuth:
|
|
79
|
+
"""Load the token→role config if present. Never raises — a missing or malformed file yields an
|
|
80
|
+
empty (disabled) TokenAuth, and the caller falls back to single-token / no-auth."""
|
|
81
|
+
path = _auth_config_path()
|
|
82
|
+
if path is None:
|
|
83
|
+
return TokenAuth({}, {})
|
|
84
|
+
try:
|
|
85
|
+
with path.open("rb") as fh:
|
|
86
|
+
data = tomllib.load(fh)
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
logger.warning("auth: %s could not be parsed (%s) — RBAC is OFF", path, type(exc).__name__)
|
|
89
|
+
return TokenAuth({}, {})
|
|
90
|
+
|
|
91
|
+
raw_roles = data.get("roles") if isinstance(data.get("roles"), dict) else {}
|
|
92
|
+
role_ops: Dict[str, List[str]] = {}
|
|
93
|
+
for role, ops in raw_roles.items():
|
|
94
|
+
if isinstance(ops, list):
|
|
95
|
+
role_ops[str(role)] = [str(o) for o in ops]
|
|
96
|
+
else:
|
|
97
|
+
# Fail CLOSED: a non-list ops value (the missing-brackets typo `reader = "search"`) must
|
|
98
|
+
# never silently become full access — deny all ops for that role instead.
|
|
99
|
+
logger.warning("auth: role %r ops must be a list (got %r) — denying all ops for it", role, ops)
|
|
100
|
+
role_ops[str(role)] = []
|
|
101
|
+
|
|
102
|
+
raw_tokens = data.get("tokens") if isinstance(data.get("tokens"), dict) else {}
|
|
103
|
+
token_hash_to_role: Dict[str, str] = {}
|
|
104
|
+
for tok, role in raw_tokens.items():
|
|
105
|
+
role = str(role)
|
|
106
|
+
# A token mapped to an undefined role fails safe: define it as deny-all (empty op list).
|
|
107
|
+
if role not in role_ops:
|
|
108
|
+
role_ops[role] = []
|
|
109
|
+
tok = str(tok)
|
|
110
|
+
if tok[:len(_SHA_PREFIX)].lower() == _SHA_PREFIX: # case-insensitive `sha256:` prefix
|
|
111
|
+
h = tok[len(_SHA_PREFIX):].strip().lower()
|
|
112
|
+
else:
|
|
113
|
+
h = hashlib.sha256(tok.encode("utf-8")).hexdigest()
|
|
114
|
+
if h:
|
|
115
|
+
token_hash_to_role[h] = role
|
|
116
|
+
|
|
117
|
+
if not token_hash_to_role:
|
|
118
|
+
logger.warning("auth: %s defines no usable [tokens] — RBAC is OFF (no token→role mapping)", path)
|
|
119
|
+
|
|
120
|
+
return TokenAuth(token_hash_to_role, role_ops)
|
|
@@ -132,6 +132,14 @@ class Gateway:
|
|
|
132
132
|
log_swallowed(f"Gateway._dispatch_single[{engine_str}.{op_str}]", exc)
|
|
133
133
|
return safe_null_result(op_str, target_str, engine=engine_str, reason="provider-error")
|
|
134
134
|
|
|
135
|
+
def allows(self, role: str, op: str) -> bool:
|
|
136
|
+
"""Whether *role* may run *op* under the current policy (True when no policy is configured).
|
|
137
|
+
Lets non-query handlers (e.g. doctor) share the same RBAC gate as query()."""
|
|
138
|
+
try:
|
|
139
|
+
return self._policy is None or self._policy.is_allowed(role, op)
|
|
140
|
+
except Exception:
|
|
141
|
+
return True
|
|
142
|
+
|
|
135
143
|
def query(
|
|
136
144
|
self,
|
|
137
145
|
op=None,
|
|
@@ -142,16 +150,25 @@ class Gateway:
|
|
|
142
150
|
project_root=None,
|
|
143
151
|
) -> Result:
|
|
144
152
|
try:
|
|
145
|
-
try:
|
|
146
|
-
self._reindexer.maybe_reindex(str(project_root or ""))
|
|
147
|
-
except Exception:
|
|
148
|
-
pass
|
|
149
|
-
|
|
150
153
|
op_str = str(op or "")
|
|
151
154
|
target_str = str(target or "")
|
|
152
155
|
engine_str = str(engine or "").strip() or "auto"
|
|
153
156
|
was_auto = engine_str == "auto"
|
|
154
157
|
|
|
158
|
+
# Policy check FIRST — a role denied for this op does NO work (no reindex, no dispatch,
|
|
159
|
+
# no cache lookup). Applies to the modern provider path; the legacy list path has none.
|
|
160
|
+
if (
|
|
161
|
+
self._legacy_providers is None
|
|
162
|
+
and self._policy is not None
|
|
163
|
+
and not self._policy.is_allowed(role, op_str)
|
|
164
|
+
):
|
|
165
|
+
return safe_null_result(op_str, target_str, reason="op-not-allowed-for-role")
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
self._reindexer.maybe_reindex(str(project_root or ""))
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
|
|
155
172
|
# Legacy list-based path (backward compat with pre-Phase-2 tests)
|
|
156
173
|
if self._legacy_providers is not None:
|
|
157
174
|
for p in self._legacy_providers:
|
|
@@ -164,10 +181,6 @@ class Gateway:
|
|
|
164
181
|
reason = "engine-unavailable" if engine is not None else "no-result"
|
|
165
182
|
return safe_null_result(op_str, target_str, reason=reason)
|
|
166
183
|
|
|
167
|
-
# Policy check — before cache lookup
|
|
168
|
-
if self._policy is not None and not self._policy.is_allowed(role, op_str):
|
|
169
|
-
return safe_null_result(op_str, target_str, reason="op-not-allowed-for-role")
|
|
170
|
-
|
|
171
184
|
# Unknown engine — reject immediately
|
|
172
185
|
if engine_str not in _KNOWN_ENGINES:
|
|
173
186
|
return safe_null_result(op_str, target_str, reason="unknown-engine")
|
|
@@ -81,24 +81,30 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
81
81
|
self.wfile.write(body)
|
|
82
82
|
self._observe(status)
|
|
83
83
|
|
|
84
|
-
def
|
|
85
|
-
"""When the server was started with a token, require ``Authorization: Bearer <token>``
|
|
86
|
-
(constant-time compare). No token configured → auth is disabled (the loopback default)."""
|
|
87
|
-
token = getattr(self.server, "auth_token", None)
|
|
88
|
-
if not token:
|
|
89
|
-
return True
|
|
84
|
+
def _bearer(self) -> str:
|
|
90
85
|
header = self.headers.get("Authorization", "")
|
|
91
86
|
prefix = "Bearer "
|
|
92
|
-
if
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
87
|
+
return header[len(prefix):].strip() if header.startswith(prefix) else ""
|
|
88
|
+
|
|
89
|
+
def _resolve_role(self) -> tuple[bool, str]:
|
|
90
|
+
"""Authenticate the request and return ``(authorized, role)``. The role is
|
|
91
|
+
**server-authoritative** — derived from the token, never from the request body. RBAC (a
|
|
92
|
+
token→role auth config) takes precedence; else a single shared token; else no auth (the
|
|
93
|
+
loopback default). Bytes-compare so a non-ASCII header can't crash ``hmac.compare_digest``."""
|
|
94
|
+
auth = getattr(self.server, "token_auth", None)
|
|
95
|
+
if auth is not None and auth.enabled:
|
|
96
|
+
role = auth.role_for(self._bearer())
|
|
97
|
+
return (role is not None, role or "")
|
|
98
|
+
token = getattr(self.server, "auth_token", None)
|
|
99
|
+
if not token:
|
|
100
|
+
return (True, "") # auth disabled → full access, no role
|
|
101
|
+
presented = self._bearer()
|
|
102
|
+
ok = bool(presented) and hmac.compare_digest(presented.encode("utf-8"), token.encode("utf-8"))
|
|
103
|
+
return (ok, "")
|
|
99
104
|
|
|
100
105
|
def do_POST(self) -> None:
|
|
101
|
-
|
|
106
|
+
ok, role = self._resolve_role()
|
|
107
|
+
if not ok:
|
|
102
108
|
self._send_json(401, {"error": "unauthorized"})
|
|
103
109
|
return
|
|
104
110
|
if self.path not in ("/code/query", "/code/doctor"):
|
|
@@ -122,11 +128,14 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
122
128
|
if not isinstance(parsed, dict):
|
|
123
129
|
self._send_json(400, {"error": "bad-request"})
|
|
124
130
|
return
|
|
131
|
+
parsed["role"] = role # server-authoritative — overrides any client-supplied role (no escalation)
|
|
125
132
|
if self.path == "/code/doctor":
|
|
126
133
|
result = code_doctor_handler(parsed)
|
|
127
134
|
else:
|
|
128
135
|
result = code_query_handler(parsed)
|
|
129
|
-
|
|
136
|
+
# An RBAC denial (query OR doctor) comes back as a safe-null with this reason — 403 it.
|
|
137
|
+
status = 403 if result.get("reason") == "op-not-allowed-for-role" else 200
|
|
138
|
+
self._send_json(status, result)
|
|
130
139
|
|
|
131
140
|
def do_GET(self) -> None:
|
|
132
141
|
parsed = urlparse(self.path)
|
|
@@ -147,9 +156,10 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
147
156
|
self._send_json(503, {"status": "not-ready"})
|
|
148
157
|
return
|
|
149
158
|
|
|
150
|
-
# Everything else is auth-gated when
|
|
151
|
-
#
|
|
152
|
-
|
|
159
|
+
# Everything else is auth-gated when auth is configured (metrics can reveal usage patterns;
|
|
160
|
+
# status reveals engine/index state). Any valid token/role may read these operational views.
|
|
161
|
+
ok, _ = self._resolve_role()
|
|
162
|
+
if not ok:
|
|
153
163
|
self._send_json(401, {"error": "unauthorized"})
|
|
154
164
|
return
|
|
155
165
|
|
|
@@ -175,7 +185,8 @@ class CodeIntelHTTPServer(ThreadingHTTPServer):
|
|
|
175
185
|
# lock-guarded (query cache, reindexer, LSP sessions, graph project cache) and the semantic
|
|
176
186
|
# engine is thread-confined with WAL, so concurrent requests are safe.
|
|
177
187
|
daemon_threads = True
|
|
178
|
-
auth_token: str | None = None # set by run() when a token is provided
|
|
188
|
+
auth_token: str | None = None # set by run() when a single shared token is provided
|
|
189
|
+
token_auth = None # set by run(): a TokenAuth for RBAC (token->role), if configured
|
|
179
190
|
|
|
180
191
|
def __init__(self, *args, **kwargs) -> None:
|
|
181
192
|
super().__init__(*args, **kwargs)
|
|
@@ -246,27 +257,36 @@ def run(
|
|
|
246
257
|
token: str | None = None,
|
|
247
258
|
) -> None:
|
|
248
259
|
configure_logging()
|
|
260
|
+
from codeintel.auth import load_auth
|
|
261
|
+
token_auth = load_auth() # RBAC (token→role), if an auth config exists
|
|
249
262
|
server_token = (token or "").strip() or None
|
|
263
|
+
authenticated = bool(server_token) or token_auth.enabled
|
|
250
264
|
if not _is_loopback(host):
|
|
251
265
|
if not allow_remote:
|
|
252
266
|
print(f"refusing to bind non-loopback host {host!r} without --allow-remote — this would "
|
|
253
267
|
f"expose a code-intel endpoint (your indexed repo) to the network", file=sys.stderr)
|
|
254
268
|
raise SystemExit(2)
|
|
255
|
-
if not
|
|
269
|
+
if not authenticated and not _no_auth_override():
|
|
256
270
|
# Fail closed: never serve an UNAUTHENTICATED endpoint on the network by accident (a
|
|
257
|
-
# containerized `serve-http --host 0.0.0.0` with no
|
|
258
|
-
print(f"refusing to serve on {host}:{port} with NO authentication — set --token
|
|
259
|
-
f"CODEINTEL_HTTP_TOKEN
|
|
260
|
-
f"for a trusted network", file=sys.stderr)
|
|
271
|
+
# containerized `serve-http --host 0.0.0.0` with no auth stops here unless overridden).
|
|
272
|
+
print(f"refusing to serve on {host}:{port} with NO authentication — set --token / "
|
|
273
|
+
f"CODEINTEL_HTTP_TOKEN, configure an RBAC auth.toml, or set CODEINTEL_ALLOW_NO_AUTH=1 "
|
|
274
|
+
f"to override for a trusted network", file=sys.stderr)
|
|
261
275
|
raise SystemExit(2)
|
|
262
276
|
|
|
263
277
|
server = CodeIntelHTTPServer((host, port), _Handler)
|
|
264
278
|
server.auth_token = server_token
|
|
265
|
-
|
|
279
|
+
server.token_auth = token_auth
|
|
280
|
+
if not _is_loopback(host) and not authenticated:
|
|
266
281
|
print(f"WARNING: serving codeintel on {host}:{port} with NO authentication "
|
|
267
282
|
f"(CODEINTEL_ALLOW_NO_AUTH set) — anyone who can reach this port can read your "
|
|
268
283
|
f"indexed repo", file=sys.stderr)
|
|
269
|
-
|
|
284
|
+
if token_auth.enabled:
|
|
285
|
+
auth_note = " (RBAC: token→role auth)"
|
|
286
|
+
elif server_token:
|
|
287
|
+
auth_note = " (bearer-token auth required)"
|
|
288
|
+
else:
|
|
289
|
+
auth_note = ""
|
|
270
290
|
print(f"Listening on http://{host}:{port}{auth_note}")
|
|
271
291
|
|
|
272
292
|
def _graceful(signum, frame) -> None:
|
|
@@ -32,7 +32,15 @@ def _build_gateway() -> Gateway:
|
|
|
32
32
|
except Exception:
|
|
33
33
|
pass
|
|
34
34
|
semantic = SemanticProvider()
|
|
35
|
-
policy
|
|
35
|
+
# RBAC: when an auth config defines restricted roles, enforce role→op; otherwise the policy is
|
|
36
|
+
# disabled (full access). The per-request role is authenticated server-side by the HTTP layer;
|
|
37
|
+
# the local MCP agent passes role="" (unrestricted), so RBAC never affects the stdio transport.
|
|
38
|
+
try:
|
|
39
|
+
from codeintel.auth import load_auth
|
|
40
|
+
auth = load_auth()
|
|
41
|
+
policy = auth.build_policy() if auth.enabled else TieringPolicy(enabled=False)
|
|
42
|
+
except Exception:
|
|
43
|
+
policy = TieringPolicy(enabled=False)
|
|
36
44
|
return Gateway(graph=graph, lsp=lsp, semantic=semantic, policy=policy, reindexer=_REINDEXER)
|
|
37
45
|
|
|
38
46
|
|
|
@@ -148,9 +156,18 @@ def code_doctor_handler(args: dict) -> dict:
|
|
|
148
156
|
|
|
149
157
|
project_root = str(args.get("project_root", "") or "")
|
|
150
158
|
deep = bool(args.get("deep", False))
|
|
159
|
+
role = str(args.get("role", "") or "")
|
|
151
160
|
# Reuse the singleton gateway's providers so the report reflects the LIVE warmed LSP
|
|
152
161
|
# session state an agent's real queries hit (and the graph project cache).
|
|
153
162
|
gw = _get_gateway()
|
|
163
|
+
# RBAC: doctor is a privileged op (engine state + a deep LSP boot on an arbitrary path), so
|
|
164
|
+
# it's gated behind the "doctor" scope — a restricted role must list it (or use "*").
|
|
165
|
+
if not gw.allows(role, "doctor"):
|
|
166
|
+
return {
|
|
167
|
+
"ok": True, "project_root": project_root, "deep": deep,
|
|
168
|
+
"summary": {"ready": 0, "total": 3, "healthy": False},
|
|
169
|
+
"engines": {}, "reason": "op-not-allowed-for-role",
|
|
170
|
+
}
|
|
154
171
|
return _doctor.run_doctor(
|
|
155
172
|
project_root, deep=deep, graph=gw.graph, lsp=gw.lsp, semantic=gw.semantic
|
|
156
173
|
)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""RBAC (0.5.0): token→role authentication + per-role op scopes, enforced server-side. The role
|
|
2
|
+
is derived from the authenticated token, never from the request body — a client cannot escalate."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import http.client
|
|
7
|
+
import json
|
|
8
|
+
import threading
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from codeintel.auth import TokenAuth, load_auth
|
|
13
|
+
from codeintel.http_server import CodeIntelHTTPServer, _Handler
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# --------------------------------------------------------------------------- auth config loading
|
|
17
|
+
|
|
18
|
+
def test_load_auth_parses_roles_and_tokens(tmp_path, monkeypatch):
|
|
19
|
+
cfg = tmp_path / "auth.toml"
|
|
20
|
+
cfg.write_text('[roles]\nadmin = ["*"]\nreader = ["search", "context"]\n'
|
|
21
|
+
'[tokens]\n"admin-tok" = "admin"\n"reader-tok" = "reader"\n')
|
|
22
|
+
monkeypatch.setenv("CODEINTEL_AUTH_CONFIG", str(cfg))
|
|
23
|
+
auth = load_auth()
|
|
24
|
+
assert auth.enabled
|
|
25
|
+
assert auth.role_for("admin-tok") == "admin"
|
|
26
|
+
assert auth.role_for("reader-tok") == "reader"
|
|
27
|
+
assert auth.role_for("unknown") is None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_load_auth_missing_file_is_disabled(tmp_path, monkeypatch):
|
|
31
|
+
monkeypatch.setenv("CODEINTEL_AUTH_CONFIG", str(tmp_path / "nope.toml"))
|
|
32
|
+
assert not load_auth().enabled
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_load_auth_malformed_file_is_disabled_not_raised(tmp_path, monkeypatch):
|
|
36
|
+
(tmp_path / "auth.toml").write_text("this is not === valid toml")
|
|
37
|
+
monkeypatch.setenv("CODEINTEL_AUTH_CONFIG", str(tmp_path / "auth.toml"))
|
|
38
|
+
assert not load_auth().enabled
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_sha256_prefixed_token_matches_plaintext(tmp_path, monkeypatch):
|
|
42
|
+
h = hashlib.sha256(b"secret-value").hexdigest()
|
|
43
|
+
(tmp_path / "auth.toml").write_text(f'[roles]\nadmin = ["*"]\n[tokens]\n"sha256:{h}" = "admin"\n')
|
|
44
|
+
monkeypatch.setenv("CODEINTEL_AUTH_CONFIG", str(tmp_path / "auth.toml"))
|
|
45
|
+
assert load_auth().role_for("secret-value") == "admin" # hashed at rest, matches plaintext
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_build_policy_restricts_and_omits_wildcard():
|
|
49
|
+
auth = TokenAuth({"h1": "admin", "h2": "reader"}, {"admin": ["*"], "reader": ["search"]})
|
|
50
|
+
pol = auth.build_policy()
|
|
51
|
+
assert pol.is_allowed("admin", "impact") # ["*"] → unrestricted
|
|
52
|
+
assert pol.is_allowed("reader", "search") # in the allowlist
|
|
53
|
+
assert not pol.is_allowed("reader", "impact") # not in the allowlist
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --------------------------------------------------------------------------- HTTP enforcement
|
|
57
|
+
|
|
58
|
+
@pytest.fixture
|
|
59
|
+
def rbac_server(tmp_path, monkeypatch):
|
|
60
|
+
(tmp_path / "auth.toml").write_text(
|
|
61
|
+
'[roles]\nadmin = ["*"]\nreader = ["search", "context"]\n'
|
|
62
|
+
'[tokens]\n"admintok" = "admin"\n"readertok" = "reader"\n'
|
|
63
|
+
)
|
|
64
|
+
monkeypatch.setenv("CODEINTEL_AUTH_CONFIG", str(tmp_path / "auth.toml"))
|
|
65
|
+
from codeintel import server as srv
|
|
66
|
+
srv._reset_gateway() # rebuild the gateway so its policy reflects THIS auth config
|
|
67
|
+
s = CodeIntelHTTPServer(("127.0.0.1", 0), _Handler)
|
|
68
|
+
s.token_auth = load_auth() # the same config the gateway policy is built from
|
|
69
|
+
threading.Thread(target=s.serve_forever, daemon=True).start()
|
|
70
|
+
yield s.server_address[1]
|
|
71
|
+
s.shutdown()
|
|
72
|
+
srv._reset_gateway() # cleanup: the next test rebuilds without this policy
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _query(port, body, token=None):
|
|
76
|
+
headers = {"Content-Type": "application/json"}
|
|
77
|
+
if token:
|
|
78
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
79
|
+
c = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
|
|
80
|
+
c.request("POST", "/code/query", json.dumps(body).encode(), headers)
|
|
81
|
+
r = c.getresponse()
|
|
82
|
+
out = json.loads(r.read())
|
|
83
|
+
c.close()
|
|
84
|
+
return r.status, out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_rbac_no_token_is_401(rbac_server):
|
|
88
|
+
assert _query(rbac_server, {"op": "search", "target": "x"})[0] == 401
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_rbac_invalid_token_is_401(rbac_server):
|
|
92
|
+
assert _query(rbac_server, {"op": "search", "target": "x"}, token="bogus")[0] == 401
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_rbac_admin_passes_the_policy_for_a_restricted_op(rbac_server):
|
|
96
|
+
status, body = _query(rbac_server, {"op": "impact", "target": "x"}, token="admintok")
|
|
97
|
+
assert status == 200
|
|
98
|
+
assert body.get("reason") != "op-not-allowed-for-role" # admin is allowed (engine may still be null)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_rbac_reader_denied_disallowed_op_is_403(rbac_server):
|
|
102
|
+
status, body = _query(rbac_server, {"op": "impact", "target": "x"}, token="readertok")
|
|
103
|
+
assert status == 403
|
|
104
|
+
assert body["reason"] == "op-not-allowed-for-role"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_rbac_reader_allowed_op_is_not_denied(rbac_server):
|
|
108
|
+
status, body = _query(rbac_server, {"op": "search", "target": "x"}, token="readertok")
|
|
109
|
+
assert status == 200
|
|
110
|
+
assert body.get("reason") != "op-not-allowed-for-role"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_rbac_role_is_server_authoritative_no_body_escalation(rbac_server):
|
|
114
|
+
# A reader token claiming role="admin" in the body must STILL be treated as a reader.
|
|
115
|
+
status, body = _query(rbac_server, {"op": "impact", "target": "x", "role": "admin"}, token="readertok")
|
|
116
|
+
assert status == 403
|
|
117
|
+
assert body["reason"] == "op-not-allowed-for-role"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_load_auth_non_list_ops_fails_closed(tmp_path, monkeypatch):
|
|
121
|
+
# `reader = "search"` (missing brackets) must DENY ALL, never silently grant full access.
|
|
122
|
+
(tmp_path / "auth.toml").write_text('[roles]\nadmin = ["*"]\nreader = "search"\n[tokens]\n"t" = "reader"\n')
|
|
123
|
+
monkeypatch.setenv("CODEINTEL_AUTH_CONFIG", str(tmp_path / "auth.toml"))
|
|
124
|
+
pol = load_auth().build_policy()
|
|
125
|
+
assert not pol.is_allowed("reader", "search") # fail-closed, not the "*" fallback
|
|
126
|
+
assert not pol.is_allowed("reader", "impact")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _doctor(port, token):
|
|
130
|
+
c = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
|
|
131
|
+
c.request("POST", "/code/doctor", b'{"project_root":"/tmp"}',
|
|
132
|
+
{"Content-Type": "application/json", "Authorization": f"Bearer {token}"})
|
|
133
|
+
r = c.getresponse()
|
|
134
|
+
body = json.loads(r.read())
|
|
135
|
+
c.close()
|
|
136
|
+
return r.status, body
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_rbac_reader_denied_doctor_is_403(rbac_server):
|
|
140
|
+
status, body = _doctor(rbac_server, "readertok") # reader has no "doctor" scope
|
|
141
|
+
assert status == 403 and body["reason"] == "op-not-allowed-for-role"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_rbac_admin_allowed_doctor(rbac_server):
|
|
145
|
+
status, body = _doctor(rbac_server, "admintok") # admin is ["*"]
|
|
146
|
+
assert status == 200 and body.get("reason") != "op-not-allowed-for-role"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_denied_op_does_no_reindex_work(monkeypatch):
|
|
150
|
+
from codeintel.gateway import Gateway
|
|
151
|
+
from codeintel.policy import TieringPolicy
|
|
152
|
+
from codeintel.reindexer import Reindexer
|
|
153
|
+
rx = Reindexer()
|
|
154
|
+
calls = {"n": 0}
|
|
155
|
+
monkeypatch.setattr(rx, "maybe_reindex", lambda root: calls.__setitem__("n", calls["n"] + 1))
|
|
156
|
+
gw = Gateway(graph=None, lsp=None, semantic=None,
|
|
157
|
+
policy=TieringPolicy(enabled=True, rules={"reader": ["search"]}), reindexer=rx)
|
|
158
|
+
denied = gw.query(op="impact", target="x", role="reader", project_root="/tmp/x")
|
|
159
|
+
assert denied["reason"] == "op-not-allowed-for-role"
|
|
160
|
+
assert calls["n"] == 0 # policy denial skips reindex entirely
|
|
161
|
+
gw.query(op="search", target="x", role="reader", project_root="/tmp/x")
|
|
162
|
+
assert calls["n"] == 1 # an allowed op proceeds to reindex
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_rbac_healthz_still_unauthenticated(rbac_server):
|
|
166
|
+
c = http.client.HTTPConnection("127.0.0.1", rbac_server, timeout=5)
|
|
167
|
+
c.request("GET", "/healthz")
|
|
168
|
+
assert c.getresponse().status == 200 # probes never require a token, even under RBAC
|
|
169
|
+
c.close()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.4.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|