oneport-debug-iam 0.1.0__py3-none-any.whl
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.
- oneport_debug_iam/__init__.py +23 -0
- oneport_debug_iam/cli.py +252 -0
- oneport_debug_iam/connectors/__init__.py +27 -0
- oneport_debug_iam/connectors/active_directory.py +169 -0
- oneport_debug_iam/connectors/okta_connector.py +124 -0
- oneport_debug_iam/demo/__init__.py +7 -0
- oneport_debug_iam/demo/runner.py +163 -0
- oneport_debug_iam/demo/sample_data.py +70 -0
- oneport_debug_iam/py.typed +1 -0
- oneport_debug_iam/simulators/__init__.py +5 -0
- oneport_debug_iam/simulators/rbac_policy_engine.py +235 -0
- oneport_debug_iam-0.1.0.dist-info/METADATA +136 -0
- oneport_debug_iam-0.1.0.dist-info/RECORD +15 -0
- oneport_debug_iam-0.1.0.dist-info/WHEEL +4 -0
- oneport_debug_iam-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2024 OnePort Debug Contributors
|
|
3
|
+
"""
|
|
4
|
+
oneport-debug-iam: Enterprise IAM-aware debugger.
|
|
5
|
+
|
|
6
|
+
Use case — The MNC problem:
|
|
7
|
+
A developer deploys a new microservice. It returns 403 on every request.
|
|
8
|
+
The error log says "insufficient_scope". But which scope? The service talks
|
|
9
|
+
to Okta, which enforces AD group membership, which maps to Azure AD roles.
|
|
10
|
+
No single person understands the full chain.
|
|
11
|
+
|
|
12
|
+
$ oneport-iam trace --user jsmith@corp.com --app payment-service --action write
|
|
13
|
+
→ Fetches JWT from Okta for the user
|
|
14
|
+
→ Inspects JWT scopes and group memberships
|
|
15
|
+
→ Queries Active Directory for group membership chain
|
|
16
|
+
→ Evaluates the app's RBAC policy against the user's actual token
|
|
17
|
+
→ AI: "User jsmith is in AD group 'Engineering' but payment-service requires
|
|
18
|
+
'payments-write' scope, granted only to 'Finance-Approvers' group.
|
|
19
|
+
Add jsmith to 'Finance-Approvers' in Active Directory, or relax the
|
|
20
|
+
payment-service RBAC policy in config/rbac.yaml:L42."
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
oneport_debug_iam/cli.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2024 OnePort Debug Contributors
|
|
3
|
+
"""
|
|
4
|
+
oneport-iam CLI
|
|
5
|
+
|
|
6
|
+
Usage examples:
|
|
7
|
+
# Trace why user jsmith gets 403 on payment-service write endpoint
|
|
8
|
+
oneport-iam trace --user jsmith@corp.com --app payment-service --action write --resource /api/v1/charge
|
|
9
|
+
|
|
10
|
+
# Introspect a token and show what it's actually authorized to do
|
|
11
|
+
oneport-iam inspect-token --token eyJhbGci... --platform okta
|
|
12
|
+
|
|
13
|
+
# Simulate what token a user would receive from Okta + evaluate against app RBAC
|
|
14
|
+
oneport-iam simulate --user jsmith --platform okta --policy config/rbac.yaml --resource /api/v1/payments --action POST
|
|
15
|
+
|
|
16
|
+
# Find all users who CAN access a resource (blast radius analysis)
|
|
17
|
+
oneport-iam who-can --resource /api/v1/admin/users --action DELETE --policy config/rbac.yaml
|
|
18
|
+
|
|
19
|
+
MNC use case:
|
|
20
|
+
Auditor asks: "Who can delete users in production?"
|
|
21
|
+
$ oneport-iam who-can --resource /api/v1/admin/users --action DELETE --policy rbac.yaml
|
|
22
|
+
→ Lists every AD group + Okta group that has DELETE permission
|
|
23
|
+
→ AI generates an access review report for compliance
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import json
|
|
29
|
+
import sys
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
import click
|
|
33
|
+
import structlog
|
|
34
|
+
|
|
35
|
+
from oneport_debug_core.config.settings import load_config
|
|
36
|
+
from oneport_debug_core.cli.output import print_rca, print_error, print_step, console
|
|
37
|
+
from oneport_debug_core.engine.orchestrator import Orchestrator
|
|
38
|
+
from oneport_debug_core.models.rca import Evidence
|
|
39
|
+
from oneport_debug_iam.simulators.rbac_policy_engine import RBACPolicyEngine, Decision
|
|
40
|
+
|
|
41
|
+
log = structlog.get_logger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@click.group()
|
|
45
|
+
@click.option("--config", default=None, type=click.Path())
|
|
46
|
+
@click.pass_context
|
|
47
|
+
def main(ctx: click.Context, config: str | None) -> None:
|
|
48
|
+
"""oneport-debug-iam — Enterprise IAM-aware authorization failure debugger."""
|
|
49
|
+
from oneport_debug_core.cli.output import ensure_utf8_console
|
|
50
|
+
ensure_utf8_console()
|
|
51
|
+
ctx.ensure_object(dict)
|
|
52
|
+
ctx.obj["config"] = load_config(Path(config) if config else None)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@main.command()
|
|
56
|
+
@click.option("--format", "output_format", default="rich", type=click.Choice(["rich", "json"]), help="Output format")
|
|
57
|
+
def demo(output_format: str) -> None:
|
|
58
|
+
"""Run a zero-config demo on a bundled 403 scenario (no Okta/API key needed)."""
|
|
59
|
+
from oneport_debug_iam.demo import run_demo
|
|
60
|
+
run_demo(json_output=(output_format == "json"))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@main.command()
|
|
64
|
+
@click.option("--user", required=True, help="User email or sAMAccountName")
|
|
65
|
+
@click.option("--app", required=True, help="Application / service name")
|
|
66
|
+
@click.option("--action", required=True, help="HTTP verb or RBAC action (GET, write, delete, ...)")
|
|
67
|
+
@click.option("--resource", default="/", help="Resource path being accessed")
|
|
68
|
+
@click.option("--platform", multiple=True, type=click.Choice(["okta", "ad", "azure_ad", "ping"]),
|
|
69
|
+
default=["okta", "ad"], help="IAM platforms to query (can specify multiple)")
|
|
70
|
+
@click.option("--policy", default=None, type=click.Path(exists=True), help="App RBAC policy YAML")
|
|
71
|
+
@click.option("--format", "output_format", default="rich", type=click.Choice(["rich", "json"]))
|
|
72
|
+
@click.pass_context
|
|
73
|
+
def trace(
|
|
74
|
+
ctx: click.Context,
|
|
75
|
+
user: str,
|
|
76
|
+
app: str,
|
|
77
|
+
action: str,
|
|
78
|
+
resource: str,
|
|
79
|
+
platform: tuple[str, ...],
|
|
80
|
+
policy: str | None,
|
|
81
|
+
output_format: str,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Trace why a user is getting 403/401 — identify the exact missing permission."""
|
|
84
|
+
config = ctx.obj["config"]
|
|
85
|
+
print_step(f"Tracing IAM failure for {user} → {action} {resource} on {app}...")
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
result = asyncio.run(_run_trace(config, user, app, action, resource, list(platform), policy))
|
|
89
|
+
except Exception as err:
|
|
90
|
+
print_error(str(err), hint="Check your IAM connector credentials in environment variables")
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
|
|
93
|
+
print_rca(result, output_format)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@main.command(name="inspect-token")
|
|
97
|
+
@click.option("--token", required=True, help="JWT or opaque access token")
|
|
98
|
+
@click.option("--platform", default="okta", type=click.Choice(["okta", "azure_ad"]))
|
|
99
|
+
@click.option("--policy", default=None, type=click.Path(exists=True))
|
|
100
|
+
@click.option("--resource", default="/", help="Resource to evaluate against")
|
|
101
|
+
@click.option("--action", default="GET")
|
|
102
|
+
@click.pass_context
|
|
103
|
+
def inspect_token(
|
|
104
|
+
ctx: click.Context, token: str, platform: str, policy: str | None, resource: str, action: str
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Decode and evaluate an access token — show what it IS and IS NOT authorized to do."""
|
|
107
|
+
import os, base64, json as _json
|
|
108
|
+
|
|
109
|
+
# JWT decode (without verification — for debugging purposes only)
|
|
110
|
+
try:
|
|
111
|
+
parts = token.split(".")
|
|
112
|
+
if len(parts) == 3:
|
|
113
|
+
payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
|
|
114
|
+
claims = _json.loads(base64.urlsafe_b64decode(payload_b64))
|
|
115
|
+
console.print("\n[bold cyan]Token Claims:[/bold cyan]")
|
|
116
|
+
console.print_json(_json.dumps(claims, indent=2, default=str))
|
|
117
|
+
|
|
118
|
+
scopes = claims.get("scope", "").split() or claims.get("scp", [])
|
|
119
|
+
groups = claims.get("groups", []) or claims.get("roles", [])
|
|
120
|
+
|
|
121
|
+
if policy:
|
|
122
|
+
engine = RBACPolicyEngine()
|
|
123
|
+
engine.load_yaml(Path(policy))
|
|
124
|
+
result = engine.evaluate(groups, scopes, resource, action)
|
|
125
|
+
_print_eval_result(result, user="<token-subject>", resource=resource, action=action)
|
|
126
|
+
else:
|
|
127
|
+
console.print("[yellow]Opaque token — cannot decode without introspection endpoint.[/yellow]")
|
|
128
|
+
except Exception as err:
|
|
129
|
+
print_error(f"Token decode failed: {err}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@main.command(name="who-can")
|
|
133
|
+
@click.option("--resource", required=True)
|
|
134
|
+
@click.option("--action", required=True)
|
|
135
|
+
@click.option("--policy", required=True, type=click.Path(exists=True))
|
|
136
|
+
@click.pass_context
|
|
137
|
+
def who_can(ctx: click.Context, resource: str, action: str, policy: str) -> None:
|
|
138
|
+
"""List all groups/roles that have permission to perform an action on a resource."""
|
|
139
|
+
engine = RBACPolicyEngine()
|
|
140
|
+
engine.load_yaml(Path(policy))
|
|
141
|
+
|
|
142
|
+
allowed_groups: set[str] = set()
|
|
143
|
+
for rule in engine._rules:
|
|
144
|
+
if (
|
|
145
|
+
engine._matches_resource(rule.resources, resource)
|
|
146
|
+
and engine._matches_action(rule.actions, action)
|
|
147
|
+
and rule.effect == "allow"
|
|
148
|
+
):
|
|
149
|
+
allowed_groups.update(rule.subjects)
|
|
150
|
+
|
|
151
|
+
console.print(f"\n[bold]Groups/roles with {action} on {resource}:[/bold]\n")
|
|
152
|
+
if allowed_groups:
|
|
153
|
+
for g in sorted(allowed_groups):
|
|
154
|
+
console.print(f" [green]✔[/green] {g}")
|
|
155
|
+
else:
|
|
156
|
+
console.print(" [red]No rules grant this permission.[/red]")
|
|
157
|
+
|
|
158
|
+
console.print(f"\n[dim]Policy: {policy}[/dim]")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def _run_trace(config, user, app, action, resource, platforms, policy_path):
|
|
162
|
+
import os
|
|
163
|
+
|
|
164
|
+
orchestrator = Orchestrator(config)
|
|
165
|
+
evidence_data: dict = {"user": user, "app": app, "action": action, "resource": resource}
|
|
166
|
+
|
|
167
|
+
# Query IAM platforms. A platform that isn't configured is skipped silently;
|
|
168
|
+
# a configured platform that fails is logged but doesn't abort the trace
|
|
169
|
+
# (Okta up + AD down should still produce a partial analysis).
|
|
170
|
+
if "okta" in platforms and os.environ.get("OKTA_ORG_URL") and os.environ.get("OKTA_API_TOKEN"):
|
|
171
|
+
try:
|
|
172
|
+
from oneport_debug_iam.connectors.okta_connector import OktaConnector
|
|
173
|
+
okta = OktaConnector(
|
|
174
|
+
org_url=os.environ["OKTA_ORG_URL"],
|
|
175
|
+
api_token=os.environ["OKTA_API_TOKEN"],
|
|
176
|
+
)
|
|
177
|
+
okta_user = await okta.get_user(user)
|
|
178
|
+
if okta_user:
|
|
179
|
+
evidence_data["okta"] = {
|
|
180
|
+
"status": okta_user.status,
|
|
181
|
+
"groups": okta_user.groups,
|
|
182
|
+
"apps": okta_user.app_assignments,
|
|
183
|
+
}
|
|
184
|
+
except Exception as err:
|
|
185
|
+
log.warning("iam.okta_query_failed", error=str(err))
|
|
186
|
+
|
|
187
|
+
ad_configured = all(os.environ.get(k) for k in ("AD_SERVER", "AD_BASE_DN", "AD_BIND_USER", "AD_BIND_PASSWORD"))
|
|
188
|
+
if "ad" in platforms and ad_configured:
|
|
189
|
+
try:
|
|
190
|
+
from oneport_debug_iam.connectors.active_directory import ActiveDirectoryConnector
|
|
191
|
+
ad = ActiveDirectoryConnector(
|
|
192
|
+
server=os.environ["AD_SERVER"],
|
|
193
|
+
base_dn=os.environ["AD_BASE_DN"],
|
|
194
|
+
bind_user=os.environ["AD_BIND_USER"],
|
|
195
|
+
bind_password=os.environ["AD_BIND_PASSWORD"],
|
|
196
|
+
)
|
|
197
|
+
ad_user = ad.get_user(user)
|
|
198
|
+
if ad_user:
|
|
199
|
+
evidence_data["active_directory"] = {
|
|
200
|
+
"is_enabled": ad_user.is_enabled,
|
|
201
|
+
"is_locked": ad_user.is_locked,
|
|
202
|
+
"direct_groups": ad_user.groups,
|
|
203
|
+
"all_groups_recursive": ad_user.all_groups,
|
|
204
|
+
"attributes": ad_user.attributes,
|
|
205
|
+
}
|
|
206
|
+
except Exception as err:
|
|
207
|
+
log.warning("iam.ad_query_failed", error=str(err))
|
|
208
|
+
|
|
209
|
+
# Nothing to analyze? Tell the user exactly what to provide.
|
|
210
|
+
if not any(k in evidence_data for k in ("okta", "active_directory")) and not policy_path:
|
|
211
|
+
raise ValueError(
|
|
212
|
+
"Nothing to analyze: no IAM identity data and no policy provided.\n"
|
|
213
|
+
" Set OKTA_ORG_URL + OKTA_API_TOKEN (and/or AD_SERVER, AD_BASE_DN, "
|
|
214
|
+
"AD_BIND_USER, AD_BIND_PASSWORD), and/or pass --policy <rbac.yaml>.\n"
|
|
215
|
+
" To see it work right now with no setup, run: oneport-iam demo"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Evaluate RBAC policy locally
|
|
219
|
+
if policy_path:
|
|
220
|
+
engine = RBACPolicyEngine()
|
|
221
|
+
engine.load_yaml(Path(policy_path))
|
|
222
|
+
all_groups = (
|
|
223
|
+
evidence_data.get("okta", {}).get("groups", [])
|
|
224
|
+
+ evidence_data.get("active_directory", {}).get("all_groups_recursive", [])
|
|
225
|
+
)
|
|
226
|
+
scopes = evidence_data.get("token_scopes", [])
|
|
227
|
+
eval_result = engine.evaluate(all_groups, scopes, resource, action)
|
|
228
|
+
evidence_data["rbac_evaluation"] = {
|
|
229
|
+
"decision": eval_result.decision.value,
|
|
230
|
+
"explanation": eval_result.explanation,
|
|
231
|
+
"missing_groups": eval_result.missing_groups,
|
|
232
|
+
"missing_scopes": eval_result.missing_scopes,
|
|
233
|
+
"blocking_rules": [{"id": r.rule_id, "file": r.source_file, "line": r.source_line} for r in eval_result.blocking_rules],
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
evidence = Evidence(source="iam-trace", raw=evidence_data)
|
|
237
|
+
return await orchestrator.analyze(
|
|
238
|
+
module="iam",
|
|
239
|
+
evidence=[evidence],
|
|
240
|
+
extra_context={"user": user, "app": app, "action": action, "resource": resource},
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _print_eval_result(result, user: str, resource: str, action: str) -> None:
|
|
245
|
+
color = "green" if result.decision.value == "allow" else "red"
|
|
246
|
+
icon = "✔" if result.decision.value == "allow" else "✖"
|
|
247
|
+
console.print(f"\n[{color}]{icon} {result.decision.value.upper()}[/{color}] {user} → {action} {resource}")
|
|
248
|
+
console.print(f"\n[dim]{result.explanation}[/dim]")
|
|
249
|
+
if result.missing_groups:
|
|
250
|
+
console.print(f"\n[yellow]Missing groups:[/yellow] {', '.join(result.missing_groups)}")
|
|
251
|
+
if result.missing_scopes:
|
|
252
|
+
console.print(f"[yellow]Missing scopes:[/yellow] {', '.join(result.missing_scopes)}")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2024 OnePort Debug Contributors
|
|
3
|
+
"""
|
|
4
|
+
IAM provider connectors.
|
|
5
|
+
|
|
6
|
+
Imports are lazy so that pulling in one connector (e.g. Okta, httpx-based) does
|
|
7
|
+
not force optional dependencies of another (e.g. Active Directory needs ldap3).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from oneport_debug_iam.connectors.okta_connector import OktaConnector
|
|
15
|
+
from oneport_debug_iam.connectors.active_directory import ActiveDirectoryConnector
|
|
16
|
+
|
|
17
|
+
__all__ = ["OktaConnector", "ActiveDirectoryConnector"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def __getattr__(name: str):
|
|
21
|
+
if name == "OktaConnector":
|
|
22
|
+
from oneport_debug_iam.connectors.okta_connector import OktaConnector
|
|
23
|
+
return OktaConnector
|
|
24
|
+
if name == "ActiveDirectoryConnector":
|
|
25
|
+
from oneport_debug_iam.connectors.active_directory import ActiveDirectoryConnector
|
|
26
|
+
return ActiveDirectoryConnector
|
|
27
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2024 OnePort Debug Contributors
|
|
3
|
+
"""
|
|
4
|
+
Active Directory connector via LDAP3.
|
|
5
|
+
|
|
6
|
+
Reads:
|
|
7
|
+
- User attributes (sAMAccountName, mail, memberOf, userAccountControl)
|
|
8
|
+
- Group membership chain (recursive nested groups)
|
|
9
|
+
- Applied Group Policy Objects (GPOs)
|
|
10
|
+
- Password and account policy (for lockout debugging)
|
|
11
|
+
|
|
12
|
+
Enterprise note: supports LDAPS (TLS) with corporate CA bundle,
|
|
13
|
+
and NTLM/Kerberos authentication for environments where Basic auth is disabled.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
import structlog
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import ldap3
|
|
23
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
24
|
+
ldap3 = None # type: ignore[assignment]
|
|
25
|
+
|
|
26
|
+
log = structlog.get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
_AD_INSTALL_HINT = (
|
|
29
|
+
"Active Directory support requires the 'ldap3' package. "
|
|
30
|
+
"Install it with: pip install 'oneport-debug-iam[ad]'"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _require_ldap3() -> None:
|
|
35
|
+
if ldap3 is None:
|
|
36
|
+
raise ImportError(_AD_INSTALL_HINT)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ADUser:
|
|
41
|
+
sam_account_name: str
|
|
42
|
+
email: str
|
|
43
|
+
display_name: str
|
|
44
|
+
distinguished_name: str
|
|
45
|
+
is_enabled: bool
|
|
46
|
+
is_locked: bool
|
|
47
|
+
groups: list[str] = field(default_factory=list) # Direct groups
|
|
48
|
+
all_groups: list[str] = field(default_factory=list) # Recursive / transitive
|
|
49
|
+
attributes: dict = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ActiveDirectoryConnector:
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
server: str,
|
|
56
|
+
base_dn: str,
|
|
57
|
+
bind_user: str,
|
|
58
|
+
bind_password: str,
|
|
59
|
+
port: int = 636,
|
|
60
|
+
use_tls: bool = True,
|
|
61
|
+
ca_certs_file: str | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
_require_ldap3()
|
|
64
|
+
tls = ldap3.Tls(
|
|
65
|
+
validate=ldap3.ssl.CERT_REQUIRED if ca_certs_file else ldap3.ssl.CERT_NONE,
|
|
66
|
+
ca_certs_file=ca_certs_file,
|
|
67
|
+
) if use_tls else None
|
|
68
|
+
|
|
69
|
+
self._server = ldap3.Server(server, port=port, use_ssl=use_tls, tls=tls, get_info=ldap3.ALL)
|
|
70
|
+
self._bind_user = bind_user
|
|
71
|
+
self._bind_password = bind_password
|
|
72
|
+
self._base_dn = base_dn
|
|
73
|
+
|
|
74
|
+
def _connect(self) -> ldap3.Connection:
|
|
75
|
+
conn = ldap3.Connection(
|
|
76
|
+
self._server,
|
|
77
|
+
user=self._bind_user,
|
|
78
|
+
password=self._bind_password,
|
|
79
|
+
authentication=ldap3.SIMPLE,
|
|
80
|
+
auto_bind=ldap3.AUTO_BIND_TLS_BEFORE_BIND if self._server.ssl else ldap3.AUTO_BIND_NO_TLS,
|
|
81
|
+
)
|
|
82
|
+
if not conn.bind():
|
|
83
|
+
raise RuntimeError(f"LDAP bind failed: {conn.last_error}")
|
|
84
|
+
return conn
|
|
85
|
+
|
|
86
|
+
def get_user(self, username: str) -> ADUser | None:
|
|
87
|
+
"""Fetch user by sAMAccountName or email."""
|
|
88
|
+
conn = self._connect()
|
|
89
|
+
filter_str = (
|
|
90
|
+
f"(&(objectClass=user)(|(sAMAccountName={username})(mail={username})))"
|
|
91
|
+
)
|
|
92
|
+
conn.search(
|
|
93
|
+
self._base_dn,
|
|
94
|
+
filter_str,
|
|
95
|
+
attributes=[
|
|
96
|
+
"sAMAccountName", "mail", "displayName", "distinguishedName",
|
|
97
|
+
"memberOf", "userAccountControl", "lockoutTime",
|
|
98
|
+
"pwdLastSet", "lastLogon", "department", "title",
|
|
99
|
+
],
|
|
100
|
+
)
|
|
101
|
+
if not conn.entries:
|
|
102
|
+
log.warning("ad.user_not_found", username=username)
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
entry = conn.entries[0]
|
|
106
|
+
uac = int(entry.userAccountControl.value or 0)
|
|
107
|
+
is_enabled = not bool(uac & 0x2) # ADS_UF_ACCOUNTDISABLE
|
|
108
|
+
is_locked = bool(uac & 0x10) # ADS_UF_LOCKOUT
|
|
109
|
+
|
|
110
|
+
direct_groups = [str(g) for g in (entry.memberOf.values or [])]
|
|
111
|
+
dn = str(entry.distinguishedName)
|
|
112
|
+
|
|
113
|
+
# Recursive group expansion
|
|
114
|
+
all_groups = self._expand_groups(conn, direct_groups)
|
|
115
|
+
|
|
116
|
+
return ADUser(
|
|
117
|
+
sam_account_name=str(entry.sAMAccountName),
|
|
118
|
+
email=str(entry.mail),
|
|
119
|
+
display_name=str(entry.displayName),
|
|
120
|
+
distinguished_name=dn,
|
|
121
|
+
is_enabled=is_enabled,
|
|
122
|
+
is_locked=is_locked,
|
|
123
|
+
groups=direct_groups,
|
|
124
|
+
all_groups=all_groups,
|
|
125
|
+
attributes={
|
|
126
|
+
"department": str(entry.department),
|
|
127
|
+
"title": str(entry.title),
|
|
128
|
+
"pwd_last_set": str(entry.pwdLastSet),
|
|
129
|
+
"last_logon": str(entry.lastLogon),
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def _expand_groups(self, conn: ldap3.Connection, groups: list[str], depth: int = 0) -> list[str]:
|
|
134
|
+
"""Recursively expand nested AD groups up to depth 5."""
|
|
135
|
+
if depth >= 5:
|
|
136
|
+
return groups
|
|
137
|
+
all_groups = list(groups)
|
|
138
|
+
for group_dn in groups:
|
|
139
|
+
conn.search(
|
|
140
|
+
group_dn,
|
|
141
|
+
"(objectClass=group)",
|
|
142
|
+
attributes=["memberOf"],
|
|
143
|
+
search_scope=ldap3.BASE,
|
|
144
|
+
)
|
|
145
|
+
if conn.entries:
|
|
146
|
+
nested = [str(g) for g in (conn.entries[0].memberOf.values or [])]
|
|
147
|
+
new_groups = [g for g in nested if g not in all_groups]
|
|
148
|
+
all_groups.extend(new_groups)
|
|
149
|
+
all_groups.extend(self._expand_groups(conn, new_groups, depth + 1))
|
|
150
|
+
return all_groups
|
|
151
|
+
|
|
152
|
+
def get_group_members(self, group_dn: str) -> list[str]:
|
|
153
|
+
"""Return sAMAccountNames of all members of a group."""
|
|
154
|
+
conn = self._connect()
|
|
155
|
+
conn.search(
|
|
156
|
+
group_dn,
|
|
157
|
+
"(objectClass=group)",
|
|
158
|
+
attributes=["member"],
|
|
159
|
+
search_scope=ldap3.BASE,
|
|
160
|
+
)
|
|
161
|
+
if not conn.entries:
|
|
162
|
+
return []
|
|
163
|
+
members = conn.entries[0].member.values or []
|
|
164
|
+
result: list[str] = []
|
|
165
|
+
for member_dn in members:
|
|
166
|
+
conn.search(str(member_dn), "(objectClass=user)", attributes=["sAMAccountName"], search_scope=ldap3.BASE)
|
|
167
|
+
if conn.entries:
|
|
168
|
+
result.append(str(conn.entries[0].sAMAccountName))
|
|
169
|
+
return result
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2024 OnePort Debug Contributors
|
|
3
|
+
"""
|
|
4
|
+
Okta connector — reads users, groups, app assignments, and token scopes via Okta API v1.
|
|
5
|
+
|
|
6
|
+
Supports both API token auth and OAuth 2.0 service-to-service auth.
|
|
7
|
+
Handles Okta rate limits automatically (429 + Retry-After header).
|
|
8
|
+
Uses async sleep (not time.sleep) so the event loop isn't blocked.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
import structlog
|
|
17
|
+
|
|
18
|
+
log = structlog.get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class OktaUser:
|
|
23
|
+
id: str
|
|
24
|
+
login: str
|
|
25
|
+
email: str
|
|
26
|
+
status: str # ACTIVE | STAGED | PROVISIONED | DEPROVISIONED | SUSPENDED | LOCKED_OUT
|
|
27
|
+
groups: list[str] = field(default_factory=list) # Group names
|
|
28
|
+
app_assignments: list[str] = field(default_factory=list) # App IDs
|
|
29
|
+
profile: dict = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class OktaTokenClaims:
|
|
34
|
+
subject: str
|
|
35
|
+
scopes: list[str]
|
|
36
|
+
groups: list[str]
|
|
37
|
+
client_id: str
|
|
38
|
+
issuer: str
|
|
39
|
+
expiry: int
|
|
40
|
+
raw_claims: dict = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class OktaConnector:
|
|
44
|
+
def __init__(self, org_url: str, api_token: str, timeout: int = 20) -> None:
|
|
45
|
+
self._base = org_url.rstrip("/") + "/api/v1"
|
|
46
|
+
self._headers = {
|
|
47
|
+
"Authorization": f"SSWS {api_token}",
|
|
48
|
+
"Accept": "application/json",
|
|
49
|
+
}
|
|
50
|
+
self._timeout = timeout
|
|
51
|
+
|
|
52
|
+
async def get_user(self, login_or_id: str) -> OktaUser | None:
|
|
53
|
+
async with httpx.AsyncClient(headers=self._headers, timeout=self._timeout) as client:
|
|
54
|
+
r = await self._get(client, f"/users/{login_or_id}")
|
|
55
|
+
if r.status_code == 404:
|
|
56
|
+
return None
|
|
57
|
+
r.raise_for_status()
|
|
58
|
+
u = r.json()
|
|
59
|
+
|
|
60
|
+
groups_r = await self._get(client, f"/users/{u['id']}/groups")
|
|
61
|
+
groups = [g["profile"]["name"] for g in (groups_r.json() if groups_r.is_success else [])]
|
|
62
|
+
|
|
63
|
+
apps_r = await self._get(client, f"/users/{u['id']}/appLinks")
|
|
64
|
+
apps = [a["appName"] for a in (apps_r.json() if apps_r.is_success else [])]
|
|
65
|
+
|
|
66
|
+
return OktaUser(
|
|
67
|
+
id=u["id"],
|
|
68
|
+
login=u["profile"]["login"],
|
|
69
|
+
email=u["profile"]["email"],
|
|
70
|
+
status=u["status"],
|
|
71
|
+
groups=groups,
|
|
72
|
+
app_assignments=apps,
|
|
73
|
+
profile=u.get("profile", {}),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def introspect_token(self, token: str, client_id: str, client_secret: str, server_id: str = "default") -> OktaTokenClaims:
|
|
77
|
+
"""Introspect an opaque or JWT access token to get its actual claims."""
|
|
78
|
+
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
79
|
+
r = await client.post(
|
|
80
|
+
f"{self._base.replace('/api/v1', '')}/oauth2/{server_id}/v1/introspect",
|
|
81
|
+
data={"token": token, "token_type_hint": "access_token"},
|
|
82
|
+
auth=(client_id, client_secret),
|
|
83
|
+
headers={"Accept": "application/json"},
|
|
84
|
+
)
|
|
85
|
+
r.raise_for_status()
|
|
86
|
+
claims = r.json()
|
|
87
|
+
|
|
88
|
+
scopes = claims.get("scope", "").split()
|
|
89
|
+
groups = claims.get("groups", [])
|
|
90
|
+
|
|
91
|
+
return OktaTokenClaims(
|
|
92
|
+
subject=claims.get("sub", ""),
|
|
93
|
+
scopes=scopes,
|
|
94
|
+
groups=groups,
|
|
95
|
+
client_id=claims.get("client_id", ""),
|
|
96
|
+
issuer=claims.get("iss", ""),
|
|
97
|
+
expiry=claims.get("exp", 0),
|
|
98
|
+
raw_claims=claims,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
async def get_app_scopes(self, app_id: str, server_id: str = "default") -> list[str]:
|
|
102
|
+
"""List all OAuth scopes configured for an authorization server."""
|
|
103
|
+
async with httpx.AsyncClient(headers=self._headers, timeout=self._timeout) as client:
|
|
104
|
+
r = await self._get(client, f"/authorizationServers/{server_id}/scopes")
|
|
105
|
+
r.raise_for_status()
|
|
106
|
+
return [s["name"] for s in r.json()]
|
|
107
|
+
|
|
108
|
+
async def _get(self, client: httpx.AsyncClient, path: str) -> httpx.Response:
|
|
109
|
+
backoff = 2.0
|
|
110
|
+
for attempt in range(4):
|
|
111
|
+
r = await client.get(f"{self._base}{path}")
|
|
112
|
+
if r.status_code == 429:
|
|
113
|
+
retry_after = float(r.headers.get("Retry-After", backoff))
|
|
114
|
+
log.warning("okta.rate_limited", retry_after=retry_after, path=path, attempt=attempt)
|
|
115
|
+
await asyncio.sleep(retry_after)
|
|
116
|
+
backoff *= 2
|
|
117
|
+
continue
|
|
118
|
+
if r.status_code == 503:
|
|
119
|
+
await asyncio.sleep(backoff)
|
|
120
|
+
backoff *= 2
|
|
121
|
+
continue
|
|
122
|
+
return r
|
|
123
|
+
# Return last response — caller decides whether to raise
|
|
124
|
+
return r
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2024 OnePort Debug Contributors
|
|
3
|
+
"""
|
|
4
|
+
Runner for `oneport-iam demo`.
|
|
5
|
+
|
|
6
|
+
Evaluates a bundled 403 scenario with the *real* RBACPolicyEngine (the same
|
|
7
|
+
engine used against live Okta/AD identities), then renders the decision, the
|
|
8
|
+
exact missing group/scope, and a root-cause analysis.
|
|
9
|
+
|
|
10
|
+
No Okta tenant, no API key, no network. Someone who just ran
|
|
11
|
+
`pip install oneport-debug-iam` sees the product work in ~2 seconds.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import tempfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from rich import box
|
|
19
|
+
from rich.panel import Panel
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
from rich.text import Text
|
|
22
|
+
|
|
23
|
+
from oneport_debug_core.cli.output import console, print_rca
|
|
24
|
+
from oneport_debug_core.models.rca import CodeLocation, RCAResult, Severity
|
|
25
|
+
from oneport_debug_iam.demo import sample_data as sd
|
|
26
|
+
from oneport_debug_iam.simulators.rbac_policy_engine import RBACPolicyEngine, Decision
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _quiet_logs() -> None:
|
|
30
|
+
import logging
|
|
31
|
+
import structlog
|
|
32
|
+
structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(logging.WARNING))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_demo(json_output: bool = False) -> RCAResult:
|
|
36
|
+
"""Run the bundled IAM 403 scenario end-to-end and render it."""
|
|
37
|
+
_quiet_logs()
|
|
38
|
+
|
|
39
|
+
tmp = Path(tempfile.mkdtemp(prefix="oneport-iam-demo-"))
|
|
40
|
+
try:
|
|
41
|
+
policy_path = tmp / "rbac.yaml"
|
|
42
|
+
policy_path.write_text(sd.POLICY_YAML, encoding="utf-8")
|
|
43
|
+
|
|
44
|
+
engine = RBACPolicyEngine()
|
|
45
|
+
engine.load_yaml(policy_path)
|
|
46
|
+
|
|
47
|
+
result = engine.evaluate(
|
|
48
|
+
user_groups=sd.USER["groups"],
|
|
49
|
+
user_scopes=sd.USER["scopes"],
|
|
50
|
+
resource=sd.REQUEST["resource"],
|
|
51
|
+
action=sd.REQUEST["action"],
|
|
52
|
+
)
|
|
53
|
+
governing = _governing_rule(engine, sd.REQUEST["resource"], sd.REQUEST["action"])
|
|
54
|
+
rca = _build_rca(result, governing)
|
|
55
|
+
|
|
56
|
+
if json_output:
|
|
57
|
+
console.print_json(rca.model_dump_json(indent=2))
|
|
58
|
+
return rca
|
|
59
|
+
|
|
60
|
+
_render_intro()
|
|
61
|
+
_render_identity()
|
|
62
|
+
_render_decision(result)
|
|
63
|
+
console.print()
|
|
64
|
+
print_rca(rca, output_format="rich")
|
|
65
|
+
_render_outro()
|
|
66
|
+
return rca
|
|
67
|
+
finally:
|
|
68
|
+
import shutil
|
|
69
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _governing_rule(engine: RBACPolicyEngine, resource: str, action: str):
|
|
73
|
+
"""The allow rule that governs this resource+action (what the user failed to satisfy)."""
|
|
74
|
+
for rule in engine._rules:
|
|
75
|
+
if (engine._matches_resource(rule.resources, resource)
|
|
76
|
+
and engine._matches_action(rule.actions, action)
|
|
77
|
+
and rule.effect == "allow"):
|
|
78
|
+
return rule
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _build_rca(result, governing) -> RCAResult:
|
|
83
|
+
locations: list[CodeLocation] = []
|
|
84
|
+
if governing is not None:
|
|
85
|
+
locations.append(CodeLocation(
|
|
86
|
+
repo=sd.APP,
|
|
87
|
+
file_path="rbac.yaml",
|
|
88
|
+
line_number=governing.source_line,
|
|
89
|
+
function_name=governing.rule_id,
|
|
90
|
+
))
|
|
91
|
+
return RCAResult(
|
|
92
|
+
module="iam",
|
|
93
|
+
severity=Severity(sd.CURATED_RCA["severity"]),
|
|
94
|
+
summary=sd.CURATED_RCA["summary"],
|
|
95
|
+
root_cause=sd.CURATED_RCA["root_cause"],
|
|
96
|
+
recommended_fix=sd.CURATED_RCA["recommended_fix"],
|
|
97
|
+
confidence=sd.CURATED_RCA["confidence"],
|
|
98
|
+
locations=locations,
|
|
99
|
+
affected_services=[sd.APP],
|
|
100
|
+
tags={
|
|
101
|
+
"user": sd.USER["login"],
|
|
102
|
+
"decision": result.decision.value,
|
|
103
|
+
"missing_groups": ",".join(result.missing_groups) or "—",
|
|
104
|
+
"missing_scopes": ",".join(result.missing_scopes) or "—",
|
|
105
|
+
},
|
|
106
|
+
model_used="bundled sample (offline demo)",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _render_intro() -> None:
|
|
111
|
+
body = Text()
|
|
112
|
+
body.append("Running on bundled sample data — no Okta tenant, no API key, no setup.\n\n", style="dim")
|
|
113
|
+
body.append("Scenario: ", style="bold")
|
|
114
|
+
body.append(f"{sd.USER['login']} gets a 403.\n")
|
|
115
|
+
body.append("Request ", style="bold")
|
|
116
|
+
body.append(f"{sd.REQUEST['action']} {sd.REQUEST['resource']}", style="cyan")
|
|
117
|
+
body.append(f" on {sd.APP}\n")
|
|
118
|
+
body.append("Evaluated locally against the app's RBAC policy.", style="dim")
|
|
119
|
+
console.print()
|
|
120
|
+
console.print(Panel(body, title="[bold]OnePort Debug — oneport-iam demo[/bold]",
|
|
121
|
+
border_style="cyan", box=box.ROUNDED))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _render_identity() -> None:
|
|
125
|
+
table = Table(title="Identity (from Okta)", box=box.SIMPLE_HEAVY, title_style="bold")
|
|
126
|
+
table.add_column("Attribute", style="cyan", no_wrap=True)
|
|
127
|
+
table.add_column("Value", style="white")
|
|
128
|
+
table.add_row("Login", sd.USER["login"])
|
|
129
|
+
table.add_row("Status", f"[green]{sd.USER['okta_status']}[/green]")
|
|
130
|
+
table.add_row("Groups", ", ".join(sd.USER["groups"]))
|
|
131
|
+
table.add_row("Scopes", ", ".join(sd.USER["scopes"]))
|
|
132
|
+
console.print()
|
|
133
|
+
console.print(table)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _render_decision(result) -> None:
|
|
137
|
+
is_deny = result.decision != Decision.ALLOW
|
|
138
|
+
color = "red" if is_deny else "green"
|
|
139
|
+
icon = "✖" if is_deny else "✔"
|
|
140
|
+
body = Text()
|
|
141
|
+
body.append(f"{icon} {result.decision.value.upper()}\n\n", style=f"bold {color}")
|
|
142
|
+
if result.missing_groups:
|
|
143
|
+
body.append("Missing group(s): ", style="yellow")
|
|
144
|
+
body.append(", ".join(result.missing_groups) + "\n")
|
|
145
|
+
if result.missing_scopes:
|
|
146
|
+
body.append("Missing scope(s): ", style="yellow")
|
|
147
|
+
body.append(", ".join(result.missing_scopes) + "\n")
|
|
148
|
+
body.append("\n")
|
|
149
|
+
body.append(result.explanation, style="dim")
|
|
150
|
+
console.print()
|
|
151
|
+
console.print(Panel(body, title="[bold]Authorization decision[/bold]",
|
|
152
|
+
border_style=color, box=box.ROUNDED))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _render_outro() -> None:
|
|
156
|
+
body = Text()
|
|
157
|
+
body.append("This ran the real RBAC engine on sample data.\n", style="dim")
|
|
158
|
+
body.append("Run it on your own identities:\n\n", style="dim")
|
|
159
|
+
body.append(" export OKTA_ORG_URL=… OKTA_API_TOKEN=…\n", style="green")
|
|
160
|
+
body.append(" oneport-iam trace --user jsmith@corp.com --app payment-service \\\n", style="green")
|
|
161
|
+
body.append(" --action POST --resource /api/v1/payments/charge --policy rbac.yaml\n", style="green")
|
|
162
|
+
console.print()
|
|
163
|
+
console.print(Panel(body, title="[bold]Next[/bold]", border_style="dim", box=box.ROUNDED))
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2024 OnePort Debug Contributors
|
|
3
|
+
"""
|
|
4
|
+
Bundled sample data for `oneport-iam demo`.
|
|
5
|
+
|
|
6
|
+
A realistic 403: an engineer clicks "refund" in an internal tool and gets
|
|
7
|
+
"Forbidden". They swear they have access. The truth, three systems deep:
|
|
8
|
+
their Okta groups grant read-only access, but the charge endpoint's policy
|
|
9
|
+
requires write privileges they were never granted.
|
|
10
|
+
|
|
11
|
+
The RBAC evaluation is run by the REAL engine; only the AI narrative is curated
|
|
12
|
+
so the demo needs no API key.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
APP = "payment-service"
|
|
17
|
+
|
|
18
|
+
# What Okta would return for this user (no Okta call in the demo).
|
|
19
|
+
USER = {
|
|
20
|
+
"login": "jsmith@corp.com",
|
|
21
|
+
"okta_status": "ACTIVE",
|
|
22
|
+
"groups": ["payments-readonly", "engineering"],
|
|
23
|
+
"scopes": ["payments:read", "profile"],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# The request that 403'd.
|
|
27
|
+
REQUEST = {"resource": "/api/v1/payments/charge", "action": "POST"}
|
|
28
|
+
|
|
29
|
+
# The application's RBAC policy — exactly as a developer would write it.
|
|
30
|
+
POLICY_YAML = """\
|
|
31
|
+
rules:
|
|
32
|
+
- id: allow-charge
|
|
33
|
+
effect: allow
|
|
34
|
+
groups: [payments-admin]
|
|
35
|
+
scopes: [payments:write]
|
|
36
|
+
resources: ["/api/v1/payments/*"]
|
|
37
|
+
actions: [POST]
|
|
38
|
+
description: Only payments-admin with write scope may charge
|
|
39
|
+
|
|
40
|
+
- id: allow-readonly
|
|
41
|
+
effect: allow
|
|
42
|
+
groups: [payments-readonly]
|
|
43
|
+
scopes: [payments:read]
|
|
44
|
+
resources: ["/api/v1/payments/*"]
|
|
45
|
+
actions: [GET]
|
|
46
|
+
description: Read-only users may list charges
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# Curated AI narrative. The missing group/scope shown next to it are computed
|
|
50
|
+
# live by the real RBAC engine, not hard-coded here.
|
|
51
|
+
CURATED_RCA = {
|
|
52
|
+
"severity": "high",
|
|
53
|
+
"summary": (
|
|
54
|
+
"jsmith@corp.com gets 403 on POST /api/v1/payments/charge: their Okta "
|
|
55
|
+
"groups grant read-only access, but the charge endpoint requires write "
|
|
56
|
+
"privileges they don't have."
|
|
57
|
+
),
|
|
58
|
+
"root_cause": (
|
|
59
|
+
"The user is in 'payments-readonly' with scope 'payments:read'. The charge "
|
|
60
|
+
"endpoint's allow rule 'allow-charge' requires the 'payments-admin' group "
|
|
61
|
+
"AND the 'payments:write' scope. No allow rule matches the user for "
|
|
62
|
+
"POST on this resource, so the request is denied (default-deny)."
|
|
63
|
+
),
|
|
64
|
+
"recommended_fix": (
|
|
65
|
+
"If this user should be able to charge, add jsmith@corp.com to the "
|
|
66
|
+
"'payments-admin' Okta group (which carries the payments:write scope). "
|
|
67
|
+
"If read-only is intended, the client should call GET, not POST."
|
|
68
|
+
),
|
|
69
|
+
"confidence": 0.9,
|
|
70
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561. This package ships inline type hints.
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2024 OnePort Debug Contributors
|
|
3
|
+
"""
|
|
4
|
+
RBACPolicyEngine — evaluates a user's effective permissions against an application's policy.
|
|
5
|
+
|
|
6
|
+
Supports policy formats used by:
|
|
7
|
+
- OPA (Open Policy Agent) Rego — the MNC standard
|
|
8
|
+
- Casbin — common in Go/Java services
|
|
9
|
+
- Custom YAML RBAC (e.g. Kubernetes-style rules)
|
|
10
|
+
- AWS IAM policy documents
|
|
11
|
+
|
|
12
|
+
The engine does NOT call OPA/Casbin servers — it re-evaluates the policy locally
|
|
13
|
+
so developers can understand exactly which rule allowed or denied the request.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
import structlog
|
|
24
|
+
|
|
25
|
+
log = structlog.get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Decision(str, Enum):
|
|
29
|
+
ALLOW = "allow"
|
|
30
|
+
DENY = "deny"
|
|
31
|
+
NOT_APPLICABLE = "not_applicable"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class PolicyRule:
|
|
36
|
+
rule_id: str
|
|
37
|
+
subjects: list[str] # groups / roles / user IDs required (OR semantics within this list)
|
|
38
|
+
resources: list[str] # resource patterns (supports * wildcard)
|
|
39
|
+
actions: list[str] # read, write, delete, * ...
|
|
40
|
+
effect: str # allow | deny
|
|
41
|
+
required_scopes: list[str] = field(default_factory=list) # OAuth scopes required (OR semantics)
|
|
42
|
+
conditions: dict = field(default_factory=dict)
|
|
43
|
+
source_file: str = ""
|
|
44
|
+
source_line: int = 0
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_blanket(self) -> bool:
|
|
48
|
+
"""True if this rule has no subject/scope restriction — applies to everyone."""
|
|
49
|
+
return not self.subjects and not self.required_scopes
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class EvaluationResult:
|
|
54
|
+
decision: Decision
|
|
55
|
+
matching_rules: list[PolicyRule]
|
|
56
|
+
blocking_rules: list[PolicyRule]
|
|
57
|
+
missing_groups: list[str]
|
|
58
|
+
missing_scopes: list[str]
|
|
59
|
+
explanation: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RBACPolicyEngine:
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
self._rules: list[PolicyRule] = []
|
|
65
|
+
|
|
66
|
+
def load_yaml(self, path: Path) -> None:
|
|
67
|
+
"""Load a Kubernetes-style RBAC YAML policy."""
|
|
68
|
+
with path.open() as f:
|
|
69
|
+
doc = yaml.safe_load(f)
|
|
70
|
+
|
|
71
|
+
source = str(path)
|
|
72
|
+
for i, rule in enumerate(doc.get("rules", []) or doc.get("policies", [])):
|
|
73
|
+
raw_subjects = self._to_list(rule.get("subjects") or rule.get("groups", []))
|
|
74
|
+
# Scopes may be expressed two ways: as `scope:`-prefixed entries in the
|
|
75
|
+
# subjects list (OAuth-style), or as a dedicated `scopes:` field. Normalise
|
|
76
|
+
# both into required_scopes so matching/missing-detection is uniform.
|
|
77
|
+
groups = [s for s in raw_subjects if not s.startswith("scope:")]
|
|
78
|
+
scope_subjects = [s.removeprefix("scope:") for s in raw_subjects if s.startswith("scope:")]
|
|
79
|
+
required_scopes = scope_subjects + self._to_list(rule.get("scopes", []))
|
|
80
|
+
self._rules.append(PolicyRule(
|
|
81
|
+
rule_id=rule.get("id", f"rule-{i}"),
|
|
82
|
+
subjects=groups,
|
|
83
|
+
resources=self._to_list(rule.get("resources", ["*"])),
|
|
84
|
+
actions=self._to_list(rule.get("verbs") or rule.get("actions", ["*"])),
|
|
85
|
+
effect=str(rule.get("effect", "allow")).lower(),
|
|
86
|
+
required_scopes=required_scopes,
|
|
87
|
+
conditions=rule.get("conditions", {}),
|
|
88
|
+
source_file=source,
|
|
89
|
+
source_line=i + 1,
|
|
90
|
+
))
|
|
91
|
+
log.info("rbac.rules_loaded", count=len(self._rules), source=source)
|
|
92
|
+
|
|
93
|
+
def load_aws_iam(self, policy_doc: dict) -> None:
|
|
94
|
+
"""Load an AWS IAM policy document."""
|
|
95
|
+
for i, stmt in enumerate(policy_doc.get("Statement", [])):
|
|
96
|
+
principals = self._to_list(stmt.get("Principal", {}).get("AWS", ["*"]))
|
|
97
|
+
resources = self._to_list(stmt.get("Resource", ["*"]))
|
|
98
|
+
actions = self._to_list(stmt.get("Action", ["*"]))
|
|
99
|
+
effect = str(stmt.get("Effect", "Allow")).lower()
|
|
100
|
+
self._rules.append(PolicyRule(
|
|
101
|
+
rule_id=stmt.get("Sid", f"stmt-{i}"),
|
|
102
|
+
subjects=principals,
|
|
103
|
+
resources=resources,
|
|
104
|
+
actions=actions,
|
|
105
|
+
effect=effect,
|
|
106
|
+
source_file="aws-iam",
|
|
107
|
+
source_line=i,
|
|
108
|
+
))
|
|
109
|
+
|
|
110
|
+
def evaluate(
|
|
111
|
+
self,
|
|
112
|
+
user_groups: list[str],
|
|
113
|
+
user_scopes: list[str],
|
|
114
|
+
resource: str,
|
|
115
|
+
action: str,
|
|
116
|
+
) -> EvaluationResult:
|
|
117
|
+
"""
|
|
118
|
+
Evaluate whether a user (defined by their groups + scopes) can
|
|
119
|
+
perform `action` on `resource`.
|
|
120
|
+
|
|
121
|
+
Specific deny rules (with a group/scope restriction) always take precedence.
|
|
122
|
+
Blanket deny rules (no subject/scope restriction — i.e. "deny everyone")
|
|
123
|
+
act as a default-deny fallback: they only apply if no allow rule grants access.
|
|
124
|
+
"""
|
|
125
|
+
matching: list[PolicyRule] = []
|
|
126
|
+
specific_denies: list[PolicyRule] = []
|
|
127
|
+
blanket_denies: list[PolicyRule] = []
|
|
128
|
+
applicable_allows: list[PolicyRule] = []
|
|
129
|
+
|
|
130
|
+
for rule in self._rules:
|
|
131
|
+
if not self._matches_resource(rule.resources, resource):
|
|
132
|
+
continue
|
|
133
|
+
if not self._matches_action(rule.actions, action):
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
subject_match = self._matches_rule(rule, user_groups, user_scopes)
|
|
137
|
+
|
|
138
|
+
if rule.effect == "deny":
|
|
139
|
+
if rule.is_blanket:
|
|
140
|
+
blanket_denies.append(rule)
|
|
141
|
+
elif subject_match:
|
|
142
|
+
specific_denies.append(rule)
|
|
143
|
+
elif rule.effect == "allow":
|
|
144
|
+
if subject_match:
|
|
145
|
+
applicable_allows.append(rule)
|
|
146
|
+
else:
|
|
147
|
+
matching.append(rule) # Rule matches resource+action but not this user
|
|
148
|
+
|
|
149
|
+
# Required groups/scopes: from allow rules the user matched resource+action on, but not subject
|
|
150
|
+
missing_groups: list[str] = []
|
|
151
|
+
missing_scopes: list[str] = []
|
|
152
|
+
for rule in matching:
|
|
153
|
+
for subj in rule.subjects:
|
|
154
|
+
if subj not in user_groups and subj != "*":
|
|
155
|
+
missing_groups.append(subj)
|
|
156
|
+
for scope in rule.required_scopes:
|
|
157
|
+
if scope not in user_scopes:
|
|
158
|
+
missing_scopes.append(scope)
|
|
159
|
+
|
|
160
|
+
if specific_denies:
|
|
161
|
+
decision = Decision.DENY
|
|
162
|
+
explanation = (
|
|
163
|
+
f"Request explicitly DENIED by rule(s): "
|
|
164
|
+
+ ", ".join(f"'{r.rule_id}' ({r.source_file}:L{r.source_line})" for r in specific_denies)
|
|
165
|
+
)
|
|
166
|
+
elif applicable_allows:
|
|
167
|
+
decision = Decision.ALLOW
|
|
168
|
+
explanation = (
|
|
169
|
+
f"Request ALLOWED by rule(s): "
|
|
170
|
+
+ ", ".join(f"'{r.rule_id}'" for r in applicable_allows)
|
|
171
|
+
)
|
|
172
|
+
elif blanket_denies:
|
|
173
|
+
decision = Decision.DENY
|
|
174
|
+
explanation = (
|
|
175
|
+
f"Request DENIED by default-deny rule(s): "
|
|
176
|
+
+ ", ".join(f"'{r.rule_id}' ({r.source_file}:L{r.source_line})" for r in blanket_denies)
|
|
177
|
+
)
|
|
178
|
+
elif matching:
|
|
179
|
+
decision = Decision.DENY
|
|
180
|
+
group_hint = f"Missing groups: {missing_groups}" if missing_groups else ""
|
|
181
|
+
scope_hint = f"Missing scopes: {missing_scopes}" if missing_scopes else ""
|
|
182
|
+
explanation = (
|
|
183
|
+
f"No matching allow rule for this user. "
|
|
184
|
+
f"{group_hint} {scope_hint}. "
|
|
185
|
+
f"Relevant rules that apply to resource+action but not this user: "
|
|
186
|
+
+ ", ".join(f"'{r.rule_id}' (requires groups={r.subjects}, scopes={r.required_scopes})" for r in matching[:3])
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
decision = Decision.NOT_APPLICABLE
|
|
190
|
+
explanation = "No policy rules apply to this resource+action combination."
|
|
191
|
+
|
|
192
|
+
return EvaluationResult(
|
|
193
|
+
decision=decision,
|
|
194
|
+
matching_rules=applicable_allows,
|
|
195
|
+
blocking_rules=specific_denies or blanket_denies,
|
|
196
|
+
missing_groups=list(set(missing_groups)),
|
|
197
|
+
missing_scopes=list(set(missing_scopes)),
|
|
198
|
+
explanation=explanation,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def _to_list(v: Any) -> list[str]:
|
|
203
|
+
if isinstance(v, list):
|
|
204
|
+
return [str(x) for x in v]
|
|
205
|
+
if v is None:
|
|
206
|
+
return []
|
|
207
|
+
return [str(v)]
|
|
208
|
+
|
|
209
|
+
@staticmethod
|
|
210
|
+
def _matches_resource(patterns: list[str], resource: str) -> bool:
|
|
211
|
+
import fnmatch
|
|
212
|
+
return any(fnmatch.fnmatch(resource, p) or p == "*" for p in patterns)
|
|
213
|
+
|
|
214
|
+
@staticmethod
|
|
215
|
+
def _matches_action(patterns: list[str], action: str) -> bool:
|
|
216
|
+
import fnmatch
|
|
217
|
+
return any(fnmatch.fnmatch(action, p) or p == "*" for p in patterns)
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def _matches_rule(rule: PolicyRule, user_groups: list[str], user_scopes: list[str]) -> bool:
|
|
221
|
+
"""
|
|
222
|
+
A rule matches a user if the user has at least one of the rule's required
|
|
223
|
+
groups (OR within groups) AND at least one of the rule's required scopes
|
|
224
|
+
(OR within scopes). An unspecified (empty) group or scope list is treated
|
|
225
|
+
as "no restriction on that dimension".
|
|
226
|
+
"""
|
|
227
|
+
group_ok = (
|
|
228
|
+
not rule.subjects
|
|
229
|
+
or any(s in user_groups or s == "*" for s in rule.subjects)
|
|
230
|
+
)
|
|
231
|
+
scope_ok = (
|
|
232
|
+
not rule.required_scopes
|
|
233
|
+
or any(s in user_scopes for s in rule.required_scopes)
|
|
234
|
+
)
|
|
235
|
+
return group_ok and scope_ok
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oneport-debug-iam
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Enterprise IAM-aware debugger: trace 403/401 failures through Okta, Active Directory, Ping Identity, and Azure AD — find the exact missing permission or policy block
|
|
5
|
+
Project-URL: Homepage, https://github.com/oneport-debug/oneport-debug
|
|
6
|
+
Project-URL: Repository, https://github.com/oneport-debug/oneport-debug
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/oneport-debug/oneport-debug/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/oneport-debug/oneport-debug/blob/main/CHANGELOG.md
|
|
9
|
+
Author: OnePort Debug Contributors
|
|
10
|
+
License: Apache-2.0
|
|
11
|
+
Keywords: active-directory,auth,azure-ad,debugging,enterprise,iam,okta,rbac,rca
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
21
|
+
Classifier: Topic :: System :: Systems Administration :: Authentication/Directory
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Requires-Dist: click>=8.1.7
|
|
25
|
+
Requires-Dist: httpx>=0.27.0
|
|
26
|
+
Requires-Dist: oneport-debug-core>=0.1.0
|
|
27
|
+
Requires-Dist: pydantic>=2.9.0
|
|
28
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
29
|
+
Requires-Dist: rich>=13.8.0
|
|
30
|
+
Provides-Extra: ad
|
|
31
|
+
Requires-Dist: ldap3>=2.9.1; extra == 'ad'
|
|
32
|
+
Provides-Extra: azure
|
|
33
|
+
Requires-Dist: azure-identity>=1.19.0; extra == 'azure'
|
|
34
|
+
Requires-Dist: msal>=1.31.0; extra == 'azure'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# oneport-debug-iam
|
|
38
|
+
|
|
39
|
+
**Enterprise IAM-aware authorization debugger.** When a user or service gets a
|
|
40
|
+
`403`/`401`, this traces the failure through your identity provider (Okta,
|
|
41
|
+
Active Directory, Azure AD) *and* re-evaluates the application's RBAC policy
|
|
42
|
+
locally — then tells you the exact missing group, scope, or blocking rule, and
|
|
43
|
+
how to fix it.
|
|
44
|
+
|
|
45
|
+
Claude Code and Copilot run with your local privileges and have zero concept of
|
|
46
|
+
corporate RBAC. This tool understands the permission matrix.
|
|
47
|
+
|
|
48
|
+
## Try it in 60 seconds (no Okta, no API key)
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install oneport-debug-iam
|
|
52
|
+
oneport-iam demo
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The demo runs the **real** RBAC engine on a bundled 403: an engineer is denied
|
|
56
|
+
`POST /api/v1/payments/charge`. You'll see their Okta groups/scopes, the
|
|
57
|
+
authorization decision, the **exact missing group and scope**, the policy rule
|
|
58
|
+
that governs the endpoint, and a root-cause analysis.
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
✖ DENY
|
|
62
|
+
Missing group(s): payments-admin
|
|
63
|
+
Missing scope(s): payments:write
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Add `--format json` for machine-readable output (Jira/SIEM/access-review pipelines).
|
|
67
|
+
|
|
68
|
+
## Use it on your own identities
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
export OKTA_ORG_URL=https://corp.okta.com
|
|
72
|
+
export OKTA_API_TOKEN=…
|
|
73
|
+
|
|
74
|
+
oneport-iam trace \
|
|
75
|
+
--user jsmith@corp.com \
|
|
76
|
+
--app payment-service \
|
|
77
|
+
--action POST \
|
|
78
|
+
--resource /api/v1/payments/charge \
|
|
79
|
+
--policy rbac.yaml
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Other commands:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
# Who can do this? (blast-radius / access review)
|
|
86
|
+
oneport-iam who-can --resource /api/v1/admin/users --action DELETE --policy rbac.yaml
|
|
87
|
+
|
|
88
|
+
# Decode a token and evaluate what it's actually authorized to do
|
|
89
|
+
oneport-iam inspect-token --token eyJhbGci... --policy rbac.yaml --resource /api/v1/payments/charge --action POST
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### RBAC policy format
|
|
93
|
+
|
|
94
|
+
```yaml
|
|
95
|
+
rules:
|
|
96
|
+
- id: allow-charge
|
|
97
|
+
effect: allow # allow | deny (deny wins)
|
|
98
|
+
groups: [payments-admin] # OR within the list
|
|
99
|
+
scopes: [payments:write] # OAuth scopes (OR); also accepts scope: entries in groups
|
|
100
|
+
resources: ["/api/v1/payments/*"] # glob
|
|
101
|
+
actions: [POST]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Environment variables
|
|
105
|
+
|
|
106
|
+
| Provider | Variables |
|
|
107
|
+
|---|---|
|
|
108
|
+
| Okta | `OKTA_ORG_URL`, `OKTA_API_TOKEN` |
|
|
109
|
+
| Active Directory | `AD_SERVER`, `AD_BASE_DN`, `AD_BIND_USER`, `AD_BIND_PASSWORD` (needs the `ad` extra) |
|
|
110
|
+
|
|
111
|
+
A provider that isn't configured is skipped; one that fails is logged but doesn't
|
|
112
|
+
abort the trace (Okta up + AD down still yields a partial analysis).
|
|
113
|
+
|
|
114
|
+
## Optional dependencies
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
pip install 'oneport-debug-iam[ad]' # Active Directory (LDAP) support
|
|
118
|
+
pip install 'oneport-debug-iam[azure]' # Azure AD / Entra ID
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The default install is httpx-based (Okta + RBAC) and pulls no LDAP/Azure stack.
|
|
122
|
+
|
|
123
|
+
## Air-gapped / on-prem AI
|
|
124
|
+
|
|
125
|
+
Set `ONEPORT_MODE=local` to run the AI analysis against a local model (Ollama /
|
|
126
|
+
vLLM) — no data leaves your network. See `oneport-debug-local`.
|
|
127
|
+
|
|
128
|
+
## Notes
|
|
129
|
+
|
|
130
|
+
- On **Git Bash (Windows)**, a `--resource /api/...` argument may get path-mangled
|
|
131
|
+
by MSYS. Prefix with `//` or run `MSYS_NO_PATHCONV=1 oneport-iam ...`. PowerShell,
|
|
132
|
+
cmd, and Linux/macOS shells are unaffected.
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
Apache-2.0
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
oneport_debug_iam/__init__.py,sha256=_LvyZHLBfW4SPlWJLtkFcIIpCMVLsHvTrJQf3uLlxEQ,1076
|
|
2
|
+
oneport_debug_iam/cli.py,sha256=RGX74C9af2ZW6-kn4gjVr4Fo7yS2K5Uxe0cB4E06dCg,11408
|
|
3
|
+
oneport_debug_iam/py.typed,sha256=lgCyp9gZfAMplkwv75pxHXKdO1FqRCF61SzGRi14E-M,65
|
|
4
|
+
oneport_debug_iam/connectors/__init__.py,sha256=uP5GOYTAylhEJ7GaHM_1BHFSPplziBZJGlfIS9E97FI,1000
|
|
5
|
+
oneport_debug_iam/connectors/active_directory.py,sha256=cLLnFxotPd7GQylLaXzeC7EFv-Eh7MwlPudvU_SX-pk,5947
|
|
6
|
+
oneport_debug_iam/connectors/okta_connector.py,sha256=mxsuOALpMVlV0CdQfPQGJ1Fd34c1kIzpxtq1AePHB7o,4663
|
|
7
|
+
oneport_debug_iam/demo/__init__.py,sha256=bZY7JiPfnrz7GCwAibdmygrrB1Vzf_3snT8MFDak89c,251
|
|
8
|
+
oneport_debug_iam/demo/runner.py,sha256=biSziYJ3otB_Nm21a47JfruDmfUqA7vnPl05lSELNGU,6216
|
|
9
|
+
oneport_debug_iam/demo/sample_data.py,sha256=44hDxFY65e54J-14bwuVeTIP7E3uZDTEUgFQD9Dmbxc,2477
|
|
10
|
+
oneport_debug_iam/simulators/__init__.py,sha256=9zMc7j_LgjoBYAwibR_-6OU3XX7yKELIJWbmbyttYO8,256
|
|
11
|
+
oneport_debug_iam/simulators/rbac_policy_engine.py,sha256=dT8MbKPAw192fKZ_El0aH7rVjNbFoLzGVhLude_Ia-E,9551
|
|
12
|
+
oneport_debug_iam-0.1.0.dist-info/METADATA,sha256=CXn3maSU8LpcT2B9Rdlh3cN2M5iVQP4pENh0F4kDxW8,4773
|
|
13
|
+
oneport_debug_iam-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
14
|
+
oneport_debug_iam-0.1.0.dist-info/entry_points.txt,sha256=apwKWRGOLDg_A-dVPhT5V55st9WWV5tbuJWJc5WGvU4,59
|
|
15
|
+
oneport_debug_iam-0.1.0.dist-info/RECORD,,
|