diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c67a5e..942d33e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - 인증: `Authorization: Bearer `. `--create-org `으로 org·키 발급, 빈 DB면 기본 org를 부트스트랩합니다. - **org console** — control-plane 서버가 `/`에서 서빙하는 단일 정적 페이지(`scanner/dashboard/console.html`). API 키로 연결해 스캔 히스토리, deploy-blocking 추이, 스캔 상세를 봅니다(프레임워크·빌드 단계 없음). - **drift 감지** — 인제스트 시 같은 org+repo의 직전 스캔 대비 **신규 deploy-blocking** 수(`new_blocking`)를 계산합니다(line-독립 지문). console과 API 응답에 노출됩니다. + - **RBAC / 멀티유저** — org별 다중 API 키에 역할(viewer/member/owner)을 부여합니다. viewer=읽기, member=스캔 인제스트, owner=webhook·키 발급 포함 전체. `POST /api/v1/keys`(owner)로 역할 지정 키를 발급합니다. 부트스트랩 키는 owner입니다. - **drift 알림 webhook** — org에 webhook URL을 설정하면(`POST /api/v1/webhook`) `new_blocking > 0`인 스캔에서 알림을 POST합니다(best-effort, 인제스트 실패 안 함). detect→alert 루프를 닫습니다. - `appguardrail scan --push ` — 스캔 후 findings를 control-plane에 POST합니다(키는 `APPGUARDRAIL_API_KEY`, repo/commit은 `GITHUB_REPOSITORY`/`GITHUB_SHA`에서 자동). CI가 매 스캔을 플랫폼에 밀어넣어 continuous-monitoring 루프를 닫습니다. - `appguardrail monitor` 워크플로가 `APPGUARDRAIL_CONTROL_PLANE_URL` secret이 설정된 경우 스캔을 control-plane에 자동 push합니다(`APPGUARDRAIL_API_KEY` secret 사용). 미설정 시 기존 SARIF+게이트 동작 그대로. diff --git a/README.md b/README.md index 364badc..653a38d 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,10 @@ scan. Open the **org console** at `http://localhost:8788/` — a single static p connects with your API key and shows scan history, the deploy-blocking trend, and per-scan detail. +Issue role-scoped API keys with `POST /api/v1/keys` (owner only): `viewer` +(read), `member` (ingest scans), or `owner` (full). The bootstrap key is an +owner. + Set a drift-alert webhook (`POST /api/v1/webhook` with `{"url":"…"}`) and the control plane notifies it whenever a scan introduces new deploy-blocking findings. diff --git a/appguardrail_core/controlplane.py b/appguardrail_core/controlplane.py index 0db4440..715224d 100644 --- a/appguardrail_core/controlplane.py +++ b/appguardrail_core/controlplane.py @@ -45,6 +45,15 @@ FOREIGN KEY (org_id) REFERENCES orgs (id) ); CREATE INDEX IF NOT EXISTS idx_scans_org ON scans (org_id, id DESC); +CREATE TABLE IF NOT EXISTS keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + org_id INTEGER NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + role TEXT NOT NULL DEFAULT 'member', + label TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (org_id) REFERENCES orgs (id) +); """ @@ -72,8 +81,13 @@ def create_org(conn: sqlite3.Connection, name: str) -> "tuple[int, str]": "INSERT INTO orgs (name, api_key_hash, created_at) VALUES (?, ?, ?)", (name, _hash_key(api_key), _now()), ) + org_id = cur.lastrowid + conn.execute( + "INSERT INTO keys (org_id, key_hash, role, label, created_at) VALUES (?, ?, ?, ?, ?)", + (org_id, _hash_key(api_key), "owner", "owner (bootstrap)", _now()), + ) conn.commit() - return cur.lastrowid, api_key + return org_id, api_key def org_for_key(conn: sqlite3.Connection, api_key: str) -> "int | None": @@ -115,6 +129,45 @@ def _send_alert(url: str, payload: dict[str, Any]) -> bool: return False +ROLES = ("viewer", "member", "owner") +_ROLE_RANK = {role: rank for rank, role in enumerate(ROLES)} + + +def has_role(role: "str | None", minimum: str) -> bool: + """True if ``role`` is at or above ``minimum`` in the viewer= _ROLE_RANK.get(minimum, 99) + + +def create_key( + conn: sqlite3.Connection, org_id: int, role: str = "member", label: "str | None" = None +) -> "tuple[int, str]": + """Issue a new API key for an org with a role. Returns (key_id, api_key).""" + role = role if role in _ROLE_RANK else "member" + api_key = "agk_" + secrets.token_urlsafe(32) + cur = conn.execute( + "INSERT INTO keys (org_id, key_hash, role, label, created_at) VALUES (?, ?, ?, ?, ?)", + (org_id, _hash_key(api_key), role, label, _now()), + ) + conn.commit() + return cur.lastrowid, api_key + + +def role_for_key(conn: sqlite3.Connection, api_key: str) -> "tuple[int, str] | None": + """Return (org_id, role) for a presented key, or None.""" + if not api_key: + return None + row = conn.execute( + "SELECT org_id, role FROM keys WHERE key_hash = ?", (_hash_key(api_key),) + ).fetchone() + if row: + return (row["org_id"], row["role"]) + # legacy/bootstrap key stored on orgs is an owner key + row = conn.execute( + "SELECT id FROM orgs WHERE api_key_hash = ?", (_hash_key(api_key),) + ).fetchone() + return (row["id"], "owner") if row else None + + def add_scan( conn: sqlite3.Connection, org_id: int, @@ -267,10 +320,10 @@ def _json(self, code, obj): self.end_headers() self.wfile.write(body) - def _org(self): + def _auth(self): hdr = self.headers.get("Authorization", "") key = hdr[7:] if hdr.startswith("Bearer ") else "" - return org_for_key(conn, key) + return role_for_key(conn, key) def do_GET(self): path = self.path.split("?", 1)[0] @@ -283,9 +336,10 @@ def do_GET(self): return if path == "/api/v1/health": return self._json(200, {"status": "ok"}) - org = self._org() - if org is None: + auth = self._auth() + if auth is None: return self._json(401, {"error": "invalid or missing API key"}) + org, _role = auth if path == "/api/v1/scans": return self._json(200, {"scans": list_scans(conn, org)}) m = re.match(r"^/api/v1/scans/(\d+)$", path) @@ -294,25 +348,44 @@ def do_GET(self): return self._json(200, scan) if scan else self._json(404, {"error": "not found"}) self._json(404, {"error": "not found"}) + def _body(self): + try: + length = int(self.headers.get("Content-Length", 0)) + return json.loads(self.rfile.read(length) or b"{}") + except (ValueError, TypeError): + return None + def do_POST(self): path = self.path.split("?", 1)[0] - if path not in ("/api/v1/scans", "/api/v1/webhook"): + if path not in ("/api/v1/scans", "/api/v1/webhook", "/api/v1/keys"): return self._json(404, {"error": "not found"}) - org = self._org() - if org is None: + auth = self._auth() + if auth is None: return self._json(401, {"error": "invalid or missing API key"}) + org, role = auth + if path == "/api/v1/webhook": - try: - length = int(self.headers.get("Content-Length", 0)) - body = json.loads(self.rfile.read(length) or b"{}") - except (ValueError, TypeError): + if not has_role(role, "owner"): + return self._json(403, {"error": "owner role required"}) + body = self._body() + if body is None: return self._json(400, {"error": "invalid JSON body"}) set_webhook(conn, org, (body or {}).get("url")) return self._json(200, {"webhook_url": (body or {}).get("url")}) - try: - length = int(self.headers.get("Content-Length", 0)) - data = json.loads(self.rfile.read(length) or b"{}") - except (ValueError, TypeError): + + if path == "/api/v1/keys": + if not has_role(role, "owner"): + return self._json(403, {"error": "owner role required"}) + body = self._body() + if body is None: + return self._json(400, {"error": "invalid JSON body"}) + new_role = (body or {}).get("role", "member") + _kid, new_key = create_key(conn, org, new_role, (body or {}).get("label")) + return self._json(201, {"api_key": new_key, "role": new_role if new_role in ROLES else "member"}) + if not has_role(role, "member"): + return self._json(403, {"error": "member role required to ingest scans"}) + data = self._body() + if data is None: return self._json(400, {"error": "invalid JSON body"}) findings = data.get("findings") if isinstance(data, dict) else data if not isinstance(findings, list): diff --git a/tests/test_controlplane.py b/tests/test_controlplane.py index 50d9850..c4917c4 100644 --- a/tests/test_controlplane.py +++ b/tests/test_controlplane.py @@ -18,6 +18,9 @@ list_scans, make_control_plane_server, org_for_key, + create_key, + role_for_key, + has_role, set_webhook, ) @@ -177,3 +180,45 @@ def test_api_set_webhook(server): base, key = server status, body = _req("POST", f"{base}/api/v1/webhook", key, {"url": "http://hook.example/y"}) assert status == 200 and body["webhook_url"] == "http://hook.example/y" + + +def test_roles_and_key_scoping(): + conn = connect(":memory:") + oid, owner_key = create_org(conn, "Acme") + # bootstrap key is an owner + assert role_for_key(conn, owner_key) == (oid, "owner") + _, mk = create_key(conn, oid, "member", "ci") + _, vk = create_key(conn, oid, "viewer") + assert role_for_key(conn, mk) == (oid, "member") + assert role_for_key(conn, vk) == (oid, "viewer") + assert role_for_key(conn, "agk_bad") is None + assert has_role("owner", "member") and has_role("member", "member") + assert not has_role("viewer", "member") and not has_role("member", "owner") + + +def test_api_role_enforcement(server): + base, owner_key = server + # forge member + viewer keys directly in the fixture DB via a fresh connect + # (the server shares the same file); simplest: create via the owner API. + status, mk = _req("POST", f"{base}/api/v1/keys", owner_key, {"role": "member"}) + assert status == 201 and mk["role"] == "member" + status, vk = _req("POST", f"{base}/api/v1/keys", owner_key, {"role": "viewer"}) + assert status == 201 and vk["role"] == "viewer" + member, viewer = mk["api_key"], vk["api_key"] + + F = {"findings": [{"severity": "CRITICAL", "rule_id": "x", "context": "app-code"}]} + # viewer: read yes, ingest no, keys no + assert _req("GET", f"{base}/api/v1/scans", viewer)[0] == 200 + for method, path, body in [("POST", "/api/v1/scans", F), + ("POST", "/api/v1/keys", {"role": "member"}), + ("POST", "/api/v1/webhook", {"url": "http://x"})]: + with pytest.raises(urllib.error.HTTPError) as e: + _req(method, f"{base}{path}", viewer, body) + assert e.value.code == 403 + # member: ingest yes, keys/webhook no + assert _req("POST", f"{base}/api/v1/scans", member, F)[0] == 201 + with pytest.raises(urllib.error.HTTPError) as e: + _req("POST", f"{base}/api/v1/webhook", member, {"url": "http://x"}) + assert e.value.code == 403 + # owner: all yes + assert _req("POST", f"{base}/api/v1/webhook", owner_key, {"url": "http://x"})[0] == 200