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.
@@ -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"
@@ -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,7 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 OnePort Debug Contributors
3
+ """Zero-config demo for oneport-iam — runs the real RBAC engine on bundled sample data."""
4
+
5
+ from oneport_debug_iam.demo.runner import run_demo
6
+
7
+ __all__ = ["run_demo"]
@@ -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,5 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 OnePort Debug Contributors
3
+ from oneport_debug_iam.simulators.rbac_policy_engine import RBACPolicyEngine, EvaluationResult, Decision
4
+
5
+ __all__ = ["RBACPolicyEngine", "EvaluationResult", "Decision"]
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ oneport-iam = oneport_debug_iam.cli:main