mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-13 09:17:41 +08:00
* ci: guard OIDC architecture boundaries * fix(ci): guard OIDC session ownership * fix(ci): ignore OIDC authorization destructuring * fix(ci): accept typed authorization destructuring --------- Co-authored-by: overtrue <anzhengchao@gmail.com>
366 lines
16 KiB
Bash
Executable File
366 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
set -euo pipefail
|
|
|
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
|
|
exec python3 - "$ROOT_DIR" "$@" <<'PY'
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
root = Path(sys.argv[1])
|
|
arguments = sys.argv[2:]
|
|
if arguments not in ([], ["--self-test"]):
|
|
raise SystemExit("usage: check_oidc_architecture_boundaries.sh [--self-test]")
|
|
|
|
fixture_file = root / "scripts/fixtures/architecture_migration_rules/oidc/cases.json"
|
|
raw_string = re.compile(r'r(#+)?"')
|
|
char_literal = re.compile(r"'(?:\\.|[^'\\\n])'")
|
|
required_modules = (
|
|
"crates/iam/src/oidc/mod.rs",
|
|
"crates/iam/src/oidc/config.rs",
|
|
"crates/iam/src/oidc/provider.rs",
|
|
"crates/iam/src/oidc/runtime.rs",
|
|
"crates/iam/src/oidc/state.rs",
|
|
"crates/iam/src/oidc/transport.rs",
|
|
)
|
|
removed_modules = (
|
|
"crates/iam/src/oidc.rs",
|
|
"crates/iam/src/oidc_state.rs",
|
|
"crates/iam/src/federation/registry.rs",
|
|
"crates/iam/src/federation/oidc",
|
|
)
|
|
admin_config_service = "rustfs/src/admin/service/oidc_config.rs"
|
|
site_replication_handler = "rustfs/src/admin/handlers/site_replication.rs"
|
|
admin_oidc_handler = "rustfs/src/admin/handlers/oidc.rs"
|
|
admin_idp_compat_handler = "rustfs/src/admin/handlers/idp_compat.rs"
|
|
|
|
|
|
def mask(source, start, end):
|
|
return "".join("\n" if character == "\n" else " " for character in source[start:end])
|
|
|
|
|
|
def code_only(source):
|
|
"""Preserve offsets while ignoring Rust comments, strings, and test items."""
|
|
output = list(source)
|
|
index = 0
|
|
while index < len(source):
|
|
start = index
|
|
if source.startswith("//", index):
|
|
end = source.find("\n", index)
|
|
index = len(source) if end < 0 else end
|
|
elif source.startswith("/*", index):
|
|
depth = 1
|
|
index += 2
|
|
while index < len(source) and depth:
|
|
if source.startswith("/*", index):
|
|
depth += 1
|
|
index += 2
|
|
elif source.startswith("*/", index):
|
|
depth -= 1
|
|
index += 2
|
|
else:
|
|
index += 1
|
|
else:
|
|
raw = raw_string.match(source, index)
|
|
if raw:
|
|
delimiter = '"' + (raw.group(1) or "")
|
|
end = source.find(delimiter, index + len(raw.group()))
|
|
index = len(source) if end < 0 else end + len(delimiter)
|
|
elif source[index] == '"':
|
|
index += 1
|
|
while index < len(source):
|
|
if source[index] == "\\":
|
|
index += 2
|
|
elif source[index] == '"':
|
|
index += 1
|
|
break
|
|
else:
|
|
index += 1
|
|
elif source[index] == "'" and (literal := char_literal.match(source, index)):
|
|
index = literal.end()
|
|
else:
|
|
index += 1
|
|
continue
|
|
output[start:index] = mask(source, start, index)
|
|
|
|
production = "".join(output)
|
|
test_item = re.compile(
|
|
r"#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]\s*(?:#\s*\[[^]]*\]\s*)*"
|
|
r"(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:mod|fn|impl|use|struct|enum|trait|const|static)\b"
|
|
)
|
|
search_from = 0
|
|
while match := test_item.search(production, search_from):
|
|
delimiter = re.search(r"[;{]", production[match.end():])
|
|
if not delimiter:
|
|
break
|
|
end = match.end() + delimiter.end()
|
|
if delimiter.group() == "{":
|
|
depth = 1
|
|
while end < len(production) and depth:
|
|
if production[end] == "{":
|
|
depth += 1
|
|
elif production[end] == "}":
|
|
depth -= 1
|
|
end += 1
|
|
production = production[: match.start()] + mask(production, match.start(), end) + production[end:]
|
|
search_from = end
|
|
return production
|
|
|
|
|
|
def find_matches(rule, path, source, pattern):
|
|
return [
|
|
(rule, path, source.count("\n", 0, match.start()) + 1)
|
|
for match in re.finditer(pattern, source, re.MULTILINE | re.DOTALL)
|
|
]
|
|
|
|
|
|
def is_struct_pattern(source, opening_brace):
|
|
depth = 1
|
|
index = opening_brace + 1
|
|
while index < len(source) and depth:
|
|
if source[index] == "{":
|
|
depth += 1
|
|
elif source[index] == "}":
|
|
depth -= 1
|
|
index += 1
|
|
if depth:
|
|
return False
|
|
suffix = source[index:].lstrip()
|
|
typed_let_pattern = suffix.startswith(":") and re.search(
|
|
r"\blet\s+(?:\w+\s*::\s*)*FederatedAuthorization\s*$", source[:opening_brace]
|
|
)
|
|
return bool(typed_let_pattern) or suffix.startswith("=>") or (suffix.startswith("=") and not suffix.startswith("=="))
|
|
|
|
|
|
def function_body(source, name):
|
|
signature = re.search(rf"\bfn\s+{name}\s*\([^)]*\)[^{{]*\{{", source)
|
|
if not signature:
|
|
return ""
|
|
depth = 1
|
|
end = signature.end()
|
|
while end < len(source) and depth:
|
|
if source[end] == "{":
|
|
depth += 1
|
|
elif source[end] == "}":
|
|
depth -= 1
|
|
end += 1
|
|
return source[signature.end(): end - 1]
|
|
|
|
|
|
def workflow_event_paths(source, event):
|
|
section = re.search(rf"^ {event}:\s*\n(.*?)(?=^ [a-z_]+:|\Z)", source, re.MULTILINE | re.DOTALL)
|
|
if not section:
|
|
return set()
|
|
paths = set()
|
|
in_paths = False
|
|
for line in section.group(1).splitlines():
|
|
if re.fullmatch(r" paths:\s*", line):
|
|
in_paths = True
|
|
continue
|
|
if not in_paths:
|
|
continue
|
|
if not line.strip() or line.lstrip().startswith("#"):
|
|
continue
|
|
entry = re.fullmatch(r" -\s+(?:[\"']([^\"']+)[\"']|([^\s#]+))\s*(?:#.*)?", line)
|
|
if not entry:
|
|
break
|
|
paths.add(entry.group(1) or entry.group(2))
|
|
return paths
|
|
|
|
|
|
def source_findings(path, source):
|
|
findings = []
|
|
if path in removed_modules or path.startswith("crates/iam/src/federation/oidc/"):
|
|
return [("oidc-module-owner", path, 1)]
|
|
if path == ".github/workflows/oidc-keycloak.yml":
|
|
expected_paths = {"crates/iam/src/oidc/**", admin_config_service}
|
|
if any(not expected_paths.issubset(workflow_event_paths(source, event)) for event in ("pull_request", "push")):
|
|
findings.append(("oidc-live-workflow", path, 1))
|
|
return findings
|
|
if not path.endswith(".rs") or path.endswith("_tests.rs") or path.endswith("test_support.rs") or "/tests/" in path:
|
|
return findings
|
|
if not (path.startswith("crates/iam/src/") or path.startswith("rustfs/src/")):
|
|
return findings
|
|
|
|
code = code_only(source)
|
|
findings += find_matches(
|
|
"oidc-legacy-runtime", path, code,
|
|
r"\b(?:FederatedIdentityRegistry|FederatedIdentityProvider|FederatedCodeExchange|OIDC_SYS|get_oidc|init_oidc_sys|init_oidc_sys_with_extra_root_ca_provider)\b",
|
|
)
|
|
|
|
if path.startswith("crates/iam/src/federation/"):
|
|
findings += find_matches(
|
|
"federation-oidc-detail", path, code,
|
|
r"\b(?:crate|super)::oidc\b|\buse\b[^;]*\boidc\b[^;]*;|\boidc::|\b(?:OidcSys|OidcClaims|OidcStateStore|OidcProviderConfig|OidcConfigSnapshot|OidcExtraRootCaProvider|ProviderRuntime|ReqwestHttpClient|openidconnect|reqwest)\b",
|
|
)
|
|
|
|
if path.startswith("crates/iam/src/oidc/"):
|
|
findings += find_matches(
|
|
"oidc-iam-session-boundary", path, code,
|
|
r"\b(?:Credentials|FederatedSessionTransaction)\b",
|
|
)
|
|
|
|
if path.startswith("crates/iam/src/") and path != "crates/iam/src/lib.rs" and not path.startswith(
|
|
("crates/iam/src/oidc/", "crates/iam/src/federation/")
|
|
):
|
|
findings += find_matches(
|
|
"oidc-runtime-owner", path, code,
|
|
r"\b(?:OidcSys|StandardOidcAdapter|build_oidc_sys_with_extra_root_ca_provider)\b",
|
|
)
|
|
|
|
if path == "crates/iam/src/oidc/config.rs":
|
|
findings += find_matches(
|
|
"oidc-config-direction", path, code,
|
|
r"\b(?:provider|runtime|state|transport)::|\b(?:ProviderRuntime|OidcSys|OidcStateStore|ReqwestHttpClient|CoreClient)\b",
|
|
)
|
|
query = re.search(r"pub\s+trait\s+OidcConfigQuery\b[^\{]*\{([^}]*)\}", code, re.DOTALL)
|
|
narrow_query = r"\s*fn\s+site_replication_snapshot\s*\(\s*&self\s*\)\s*->\s*OidcSiteReplicationSnapshot\s*;\s*"
|
|
if not query or not re.fullmatch(narrow_query, query.group(1)):
|
|
findings.append(("oidc-query-shape", path, 1))
|
|
provider = re.search(r"pub\s+struct\s+OidcSiteReplicationProvider\s*\{([^}]*)\}", code, re.DOTALL)
|
|
provider_shape = (
|
|
r"\s*pub\s+provider_id\s*:\s*String\s*,"
|
|
r"\s*pub\s+claim_name\s*:\s*String\s*,"
|
|
r"\s*pub\s+role_policy\s*:\s*String\s*,"
|
|
r"\s*pub\s+client_id\s*:\s*String\s*,"
|
|
r"\s*pub\s+hashed_client_secret\s*:\s*String\s*,?\s*"
|
|
)
|
|
if not provider or not re.fullmatch(provider_shape, provider.group(1)):
|
|
findings.append(("oidc-site-replication-shape", path, 1))
|
|
snapshot = re.search(r"pub\s+struct\s+OidcSiteReplicationSnapshot\s*\{([^}]*)\}", code, re.DOTALL)
|
|
if not snapshot or not re.fullmatch(r"\s*providers\s*:\s*Vec\s*<\s*OidcSiteReplicationProvider\s*>\s*,?\s*", snapshot.group(1)):
|
|
findings.append(("oidc-site-replication-shape", path, 1))
|
|
|
|
if path.startswith("rustfs/src/") and path != "rustfs/src/startup_auth.rs":
|
|
findings += find_matches(
|
|
"oidc-runtime-owner", path, code,
|
|
r"\b(?:OidcSys|StandardOidcAdapter|build_oidc_sys_with_extra_root_ca_provider)\b",
|
|
)
|
|
|
|
if path == admin_oidc_handler:
|
|
findings += find_matches(
|
|
"oidc-admin-config-owner", path, code,
|
|
r"\b(?:read_admin_config_without_migrate|read_admin_server_config_snapshot|save_admin_server_config_snapshot|current_object_store_handle_for_context|ServerConfig|KVS|build_upsert_provider_config|upsert_persisted_provider_config|delete_persisted_provider_config|persisted_provider_secret|validate_oidc_provider_config_with_extra_root_ca|load_oidc_config_snapshot|current_oidc_extra_root_ca_material)\b",
|
|
)
|
|
if path == admin_idp_compat_handler:
|
|
findings += find_matches(
|
|
"oidc-admin-config-owner", path, code,
|
|
r"\b(?:read_admin_config_without_migrate|read_admin_server_config_snapshot|save_admin_server_config_snapshot|current_object_store_handle_for_context|build_upsert_provider_config|upsert_persisted_provider_config|delete_persisted_provider_config|persisted_provider_secret|validate_oidc_provider_config_with_extra_root_ca|current_oidc_extra_root_ca_material)\b",
|
|
)
|
|
if path == admin_config_service:
|
|
findings += find_matches("oidc-admin-service-layer", path, code, r"\b(?:s3s|S3Error|S3Result|s3_error)\b")
|
|
for operation in ("list_config", "upsert_config", "delete_config", "validate_config"):
|
|
if not re.search(rf"pub\(crate\)\s+async\s+fn\s+{operation}\s*\(", code):
|
|
findings.append(("oidc-admin-config-owner", path, 1))
|
|
for storage_operation in ("read_admin_server_config_snapshot", "save_admin_server_config_snapshot"):
|
|
if not re.search(rf"\b{storage_operation}\s*\(", code):
|
|
findings.append(("oidc-admin-config-owner", path, 1))
|
|
if path == site_replication_handler:
|
|
findings += find_matches(
|
|
"oidc-site-replication-consumer", path, code,
|
|
r"\b(?:OidcConfigSnapshot|OidcProviderConfig|load_oidc_config_snapshot)\b",
|
|
)
|
|
oidc_mapping = function_body(code, "open_id_settings_from_snapshot")
|
|
if not oidc_mapping or re.search(r"\b(?:hash_client_secret|Sha256)\b|\.\s*(?:config_snapshot|client_secret)\b", oidc_mapping):
|
|
findings.append(("oidc-site-replication-consumer", path, 1))
|
|
if not re.search(r"\.\s*site_replication_snapshot\s*\(", code):
|
|
findings.append(("oidc-site-replication-consumer", path, 1))
|
|
|
|
for match in re.finditer(r"\bFederatedAuthorization\s*\{", code):
|
|
preceding_word = re.search(r"\b(\w+)\s*$", code[: match.start()])
|
|
if preceding_word and preceding_word.group(1) in ("struct", "impl"):
|
|
continue
|
|
if is_struct_pattern(code, match.end() - 1):
|
|
continue
|
|
if path != "crates/iam/src/federation/mapper.rs":
|
|
findings.append(("federation-authorization-owner", path, code.count("\n", 0, match.start()) + 1))
|
|
|
|
if path == "rustfs/src/app/context/interfaces.rs":
|
|
paired_snapshot = (
|
|
r"pub\s+type\s+FederatedIdentityRuntimeSnapshot\s*=\s*\(\s*"
|
|
r"Arc\s*<\s*FederatedIdentityService\s*>\s*,\s*"
|
|
r"Arc\s*<\s*dyn\s+OidcConfigQuery\s*>\s*\)\s*;"
|
|
)
|
|
if not re.search(paired_snapshot, code):
|
|
findings.append(("oidc-runtime-pair", path, 1))
|
|
if path == "rustfs/src/app/context/handles.rs":
|
|
runtime = re.search(r"struct\s+FederatedIdentityRuntime\s*\{([^}]*)\}", code, re.DOTALL)
|
|
if not runtime or not re.search(r"\bservice\s*:\s*Arc\s*<\s*FederatedIdentityService\s*>", runtime.group(1)) or not re.search(
|
|
r"\boidc_config_query\s*:\s*Arc\s*<\s*dyn\s+OidcConfigQuery\s*>", runtime.group(1)
|
|
):
|
|
findings.append(("oidc-runtime-pair", path, 1))
|
|
return findings
|
|
|
|
|
|
def layout_findings(exists=None, service_modules=None):
|
|
if exists is None:
|
|
exists = lambda path: (root / path).exists()
|
|
if service_modules is None:
|
|
service_modules = (root / "rustfs/src/admin/service/mod.rs").read_text()
|
|
findings = []
|
|
for path in required_modules:
|
|
if not exists(path):
|
|
findings.append(("oidc-module-owner", path, 1))
|
|
for path in removed_modules:
|
|
if exists(path):
|
|
findings.append(("oidc-module-owner", path, 1))
|
|
if not exists(admin_config_service):
|
|
findings.append(("oidc-admin-config-owner", admin_config_service, 1))
|
|
if not re.search(r"\bmod\s+oidc_config\s*;", service_modules):
|
|
findings.append(("oidc-admin-config-owner", "rustfs/src/admin/service/mod.rs", 1))
|
|
return findings
|
|
|
|
|
|
def fixture_findings():
|
|
cases = json.loads(fixture_file.read_text())
|
|
actual = []
|
|
expected = []
|
|
for case in cases:
|
|
case_id = case["id"]
|
|
expected.extend(f"{rule}|{case_id}" for rule in case["expected"])
|
|
if "layout" in case:
|
|
layout = case["layout"]
|
|
present = set(required_modules) | {admin_config_service}
|
|
present.difference_update(layout.get("missing", []))
|
|
present.update(layout.get("restored", []))
|
|
findings = layout_findings(present.__contains__, layout.get("service_modules", "mod oidc_config;"))
|
|
else:
|
|
findings = source_findings(case["path"], case["source"])
|
|
actual.extend(f"{rule}|{case_id}" for rule, _, _ in findings)
|
|
return sorted(set(actual)), sorted(set(expected))
|
|
|
|
|
|
def repository_findings():
|
|
findings = layout_findings()
|
|
for base in ("crates/iam/src", "rustfs/src"):
|
|
for file in sorted((root / base).rglob("*.rs")):
|
|
path = file.relative_to(root).as_posix()
|
|
findings.extend(source_findings(path, file.read_text()))
|
|
workflow = ".github/workflows/oidc-keycloak.yml"
|
|
findings.extend(source_findings(workflow, (root / workflow).read_text()))
|
|
return findings
|
|
|
|
|
|
actual, expected = fixture_findings()
|
|
if actual != expected:
|
|
print("OIDC architecture guard fixture mismatch:", file=sys.stderr)
|
|
print(f" expected: {expected}", file=sys.stderr)
|
|
print(f" actual: {actual}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
|
|
if arguments == ["--self-test"]:
|
|
print("OIDC architecture guard fixtures passed.")
|
|
raise SystemExit(0)
|
|
|
|
findings = repository_findings()
|
|
for rule, path, line in findings:
|
|
print(f"OIDC architecture boundary failed: {rule}: {path}:{line}", file=sys.stderr)
|
|
if findings:
|
|
raise SystemExit(1)
|
|
print("OIDC architecture boundaries passed.")
|
|
PY
|