mirror of
https://gitee.com/pnoker/iot-dc3.git
synced 2026-11-01 02:31:46 +08:00
Add OAuth metadata, registration, token, revoke, introspect, MCP catalog, connection, whitelist, and audit runtime in auth. Expose gateway /.well-known OAuth resource metadata and /mcp JSON-RPC initialize, ping, tools/list, and tools/call with principal forwarding and high-risk call safeguards.
213 lines
7.1 KiB
Python
213 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
|
|
#
|
|
# Copyright 2016-present the IoT DC3 original author or authors.
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as
|
|
# published by the Free Software Foundation, either version 3 of the
|
|
# License, or (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
#
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
|
|
MAPPING_RE = re.compile(r"@(GetMapping|PostMapping|PutMapping|DeleteMapping|RequestMapping)\b")
|
|
PRE_AUTHORIZE_RE = re.compile(r"@PreAuthorize\s*\((?P<expr>.*)\)")
|
|
PERM_CAN_RE = re.compile(r"@perm\.can\(\s*['\"]([a-z0-9_]+)['\"]\s*,\s*['\"]([a-z]+)['\"]\s*\)")
|
|
CLASS_RE = re.compile(r"\bclass\s+([A-Za-z_][A-Za-z0-9_]*)\b")
|
|
METHOD_RE = re.compile(
|
|
r"\b(public|protected|private)\s+"
|
|
r"(?:static\s+)?(?:final\s+)?(?:<[^>]+>\s+)?"
|
|
r"[\w<>\[\],.? extends super]+\s+"
|
|
r"(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*\("
|
|
)
|
|
|
|
VALID_SCOPES = {"add", "delete", "update", "get", "list"}
|
|
|
|
# Token pre-login methods are covered by @PublicEndpoint and do not need to be
|
|
# listed here. Runtime MCP tool exposure must not bypass controller security.
|
|
EXPLICIT_PUBLIC_METHODS = set()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Annotation:
|
|
line: int
|
|
text: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Endpoint:
|
|
path: Path
|
|
line: int
|
|
class_name: str
|
|
method_name: str
|
|
annotations: list[Annotation]
|
|
class_annotations: list[Annotation]
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
parser = argparse.ArgumentParser(
|
|
description="Audit controller authorization annotations and resource-registration closure."
|
|
)
|
|
parser.add_argument("--root", type=Path, default=repo_root)
|
|
return parser.parse_args()
|
|
|
|
|
|
def java_files(root: Path) -> list[Path]:
|
|
return sorted(
|
|
path for path in root.glob("**/src/main/java/**/*Controller.java")
|
|
if "target" not in path.parts
|
|
)
|
|
|
|
|
|
def join_annotation(lines: list[str]) -> str:
|
|
return " ".join(line.strip() for line in lines)
|
|
|
|
|
|
def collect_endpoints(path: Path) -> list[Endpoint]:
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
endpoints: list[Endpoint] = []
|
|
pending: list[Annotation] = []
|
|
current_annotation: list[str] | None = None
|
|
current_annotation_line = 0
|
|
paren_balance = 0
|
|
class_name = path.stem
|
|
class_annotations: list[Annotation] = []
|
|
|
|
for line_no, line in enumerate(lines, start=1):
|
|
stripped = line.strip()
|
|
|
|
if current_annotation is not None:
|
|
current_annotation.append(stripped)
|
|
paren_balance += stripped.count("(") - stripped.count(")")
|
|
if paren_balance <= 0:
|
|
pending.append(Annotation(current_annotation_line, join_annotation(current_annotation)))
|
|
current_annotation = None
|
|
continue
|
|
|
|
if stripped.startswith("@"):
|
|
current_annotation = [stripped]
|
|
current_annotation_line = line_no
|
|
paren_balance = stripped.count("(") - stripped.count(")")
|
|
if paren_balance <= 0:
|
|
pending.append(Annotation(current_annotation_line, join_annotation(current_annotation)))
|
|
current_annotation = None
|
|
continue
|
|
|
|
if not stripped or stripped.startswith("//") or stripped.startswith("*") or stripped.startswith("/*"):
|
|
continue
|
|
|
|
class_match = CLASS_RE.search(stripped)
|
|
if class_match:
|
|
class_name = class_match.group(1)
|
|
class_annotations = pending
|
|
pending = []
|
|
continue
|
|
|
|
method_match = METHOD_RE.search(stripped)
|
|
if method_match:
|
|
if any(MAPPING_RE.search(annotation.text) for annotation in pending):
|
|
endpoints.append(Endpoint(
|
|
path=path,
|
|
line=line_no,
|
|
class_name=class_name,
|
|
method_name=method_match.group("name"),
|
|
annotations=pending,
|
|
class_annotations=class_annotations,
|
|
))
|
|
pending = []
|
|
continue
|
|
|
|
pending = []
|
|
|
|
return endpoints
|
|
|
|
|
|
def annotation_texts(endpoint: Endpoint) -> list[str]:
|
|
return [annotation.text for annotation in endpoint.annotations]
|
|
|
|
|
|
def class_annotation_texts(endpoint: Endpoint) -> list[str]:
|
|
return [annotation.text for annotation in endpoint.class_annotations]
|
|
|
|
|
|
def is_public(endpoint: Endpoint) -> bool:
|
|
if (endpoint.class_name, endpoint.method_name) in EXPLICIT_PUBLIC_METHODS:
|
|
return True
|
|
texts = annotation_texts(endpoint) + class_annotation_texts(endpoint)
|
|
return any("@PublicEndpoint" in text for text in texts)
|
|
|
|
|
|
def pre_authorize_expr(endpoint: Endpoint) -> str | None:
|
|
for annotation in endpoint.annotations + endpoint.class_annotations:
|
|
match = PRE_AUTHORIZE_RE.search(annotation.text)
|
|
if match:
|
|
return match.group("expr")
|
|
return None
|
|
|
|
|
|
def validate_endpoint(endpoint: Endpoint, root: Path) -> list[str]:
|
|
if is_public(endpoint):
|
|
return []
|
|
|
|
label = f"{endpoint.path.relative_to(root)}:{endpoint.line} {endpoint.class_name}#{endpoint.method_name}"
|
|
expr = pre_authorize_expr(endpoint)
|
|
if expr is None:
|
|
return [f"{label}: missing @PreAuthorize or @PublicEndpoint"]
|
|
|
|
match = PERM_CAN_RE.search(expr)
|
|
if not match:
|
|
return [f"{label}: @PreAuthorize must use @perm.can('domain', 'scope') so resource registration can mirror it"]
|
|
|
|
domain, scope = match.groups()
|
|
errors: list[str] = []
|
|
if scope not in VALID_SCOPES:
|
|
errors.append(f"{label}: unsupported permission scope '{scope}'")
|
|
if not domain:
|
|
errors.append(f"{label}: permission domain is blank")
|
|
return errors
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
root = args.root.resolve()
|
|
endpoints = [endpoint for path in java_files(root) for endpoint in collect_endpoints(path)]
|
|
errors = [error for endpoint in endpoints for error in validate_endpoint(endpoint, root)]
|
|
|
|
if errors:
|
|
print("Controller permission audit failed:", file=sys.stderr)
|
|
for error in errors:
|
|
print(f"- {error}", file=sys.stderr)
|
|
print("", file=sys.stderr)
|
|
print(
|
|
"Each controller mapping must be guarded by @PreAuthorize(\"@perm.can('domain', 'scope')\") "
|
|
"or explicitly marked @PublicEndpoint.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
guarded = len([endpoint for endpoint in endpoints if not is_public(endpoint)])
|
|
public = len(endpoints) - guarded
|
|
print(f"Controller permission audit passed: endpoints={len(endpoints)}, guarded={guarded}, public={public}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|