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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- 인증: `Authorization: Bearer <api_key>`. `--create-org <name>`으로 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 <url>` — 스캔 후 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+게이트 동작 그대로.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
105 changes: 89 additions & 16 deletions appguardrail_core/controlplane.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
"""


Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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<member<owner order."""
return _ROLE_RANK.get(role or "", -1) >= _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,
Expand Down Expand Up @@ -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]
Expand All @@ -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)
Expand All @@ -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):
Expand Down
45 changes: 45 additions & 0 deletions tests/test_controlplane.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
list_scans,
make_control_plane_server,
org_for_key,
create_key,
role_for_key,
has_role,
set_webhook,
)

Expand Down Expand Up @@ -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