Skip to content

Commit dbb3886

Browse files
authored
Merge pull request #8 from PredicateSystems/phase2_harden_Entra
Entra ID support
2 parents 6cfd458 + e6e2fc7 commit dbb3886

11 files changed

Lines changed: 732 additions & 0 deletions

.env.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,15 @@ OKTA_OBO_COMPAT_CHECK_ENABLED=0
1212

1313
# Set to 1/true only if your Okta tenant supports token exchange/OBO.
1414
OKTA_SUPPORTS_TOKEN_EXCHANGE=0
15+
16+
# Entra OBO compatibility checks (examples/tests)
17+
ENTRA_TENANT_ID=<your-entra-tenant-id>
18+
ENTRA_CLIENT_ID=<your-entra-client-id>
19+
ENTRA_CLIENT_SECRET=<your-entra-client-secret>
20+
ENTRA_SCOPE=api://predicate-authority/.default
21+
ENTRA_AUTHORITY_HOST=login.microsoftonline.com
22+
ENTRA_AUTHORITY_SCHEME=https
23+
ENTRA_OBO_COMPAT_CHECK_ENABLED=0
24+
ENTRA_SUPPORTS_OBO=0
25+
# Optional; required only when running true Entra OBO exchange path.
26+
ENTRA_USER_ASSERTION=

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,31 @@ See runnable examples in:
131131
- `examples/mcp_tool_guard_example.py`
132132
- `examples/outbound_http_guard_example.py`
133133

134+
### Entra quick command (compatibility check)
135+
136+
```bash
137+
set -a && source .env && set +a
138+
python examples/delegation/entra_obo_compat_demo.py \
139+
--tenant-id "$ENTRA_TENANT_ID" \
140+
--client-id "$ENTRA_CLIENT_ID" \
141+
--client-secret "$ENTRA_CLIENT_SECRET" \
142+
--scope "$ENTRA_SCOPE"
143+
```
144+
145+
### Local IdP quick command
146+
147+
```bash
148+
export LOCAL_IDP_SIGNING_KEY="replace-with-strong-secret"
149+
predicate-authorityd \
150+
--host 127.0.0.1 \
151+
--port 8787 \
152+
--mode local_only \
153+
--policy-file examples/authorityd/policy.json \
154+
--identity-mode local-idp \
155+
--local-idp-issuer "http://localhost/predicate-local-idp" \
156+
--local-idp-audience "api://predicate-authority"
157+
```
158+
134159
## Operations CLI
135160

136161
`predicate-authority` provides an ops-focused CLI for sidecar/runtime workflows.

docs/authorityd-operations.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,43 @@ Notes:
230230
- omit `--supports-token-exchange` for tenants that do not support OBO/token exchange,
231231
- script reports whether delegation path should use IdP token exchange or authority mandate delegation.
232232

233+
### Entra OBO compatibility (capability-gated)
234+
235+
```bash
236+
export ENTRA_OBO_COMPAT_CHECK_ENABLED=1
237+
238+
# Tenant supports OBO and user assertion is available:
239+
export ENTRA_SUPPORTS_OBO=true
240+
export ENTRA_USER_ASSERTION="<user-assertion-jwt>"
241+
python3 -m pytest tests/test_entra_obo_compatibility.py -k "live_check_when_enabled"
242+
243+
# Tenant does NOT support OBO (or app policy not enabled):
244+
export ENTRA_SUPPORTS_OBO=false
245+
python3 -m pytest tests/test_entra_obo_compatibility.py -k "live_check_when_enabled"
246+
```
247+
248+
Run demo script:
249+
250+
```bash
251+
python examples/delegation/entra_obo_compat_demo.py \
252+
--tenant-id "$ENTRA_TENANT_ID" \
253+
--client-id "$ENTRA_CLIENT_ID" \
254+
--client-secret "$ENTRA_CLIENT_SECRET" \
255+
--scope "$ENTRA_SCOPE"
256+
```
257+
258+
If OBO is supported and you have a user assertion:
259+
260+
```bash
261+
python examples/delegation/entra_obo_compat_demo.py \
262+
--tenant-id "$ENTRA_TENANT_ID" \
263+
--client-id "$ENTRA_CLIENT_ID" \
264+
--client-secret "$ENTRA_CLIENT_SECRET" \
265+
--scope "$ENTRA_SCOPE" \
266+
--user-assertion "$ENTRA_USER_ASSERTION" \
267+
--supports-obo
268+
```
269+
233270
### Secret storage policy (Okta credentials)
234271

235272
- never commit Okta client secrets/API tokens/private keys to repo files,

docs/predicate-authority-user-manual.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,57 @@ If your tenant does not support token exchange, omit
278278

279279
---
280280

281+
## Entra OBO compatibility check (capability-gated)
282+
283+
Use this when validating Entra on-behalf-of delegation support before production rollout.
284+
285+
### 1) Set environment variables
286+
287+
```bash
288+
export ENTRA_TENANT_ID="<entra-tenant-id>"
289+
export ENTRA_CLIENT_ID="<entra-client-id>"
290+
export ENTRA_CLIENT_SECRET="<entra-client-secret>"
291+
export ENTRA_SCOPE="api://predicate-authority/.default"
292+
```
293+
294+
### 2) Run compatibility test
295+
296+
```bash
297+
# OBO not supported/configured:
298+
export ENTRA_OBO_COMPAT_CHECK_ENABLED=1
299+
export ENTRA_SUPPORTS_OBO=false
300+
python -m pytest tests/test_entra_obo_compatibility.py -k "live_check_when_enabled"
301+
302+
# OBO supported and user assertion available:
303+
export ENTRA_OBO_COMPAT_CHECK_ENABLED=1
304+
export ENTRA_SUPPORTS_OBO=true
305+
export ENTRA_USER_ASSERTION="<user-assertion-jwt>"
306+
python -m pytest tests/test_entra_obo_compatibility.py -k "live_check_when_enabled"
307+
```
308+
309+
### 3) Run demo script in `examples/`
310+
311+
```bash
312+
python examples/delegation/entra_obo_compat_demo.py \
313+
--tenant-id "$ENTRA_TENANT_ID" \
314+
--client-id "$ENTRA_CLIENT_ID" \
315+
--client-secret "$ENTRA_CLIENT_SECRET" \
316+
--scope "$ENTRA_SCOPE"
317+
```
318+
319+
If OBO is supported and assertion is available, add:
320+
321+
```bash
322+
--user-assertion "$ENTRA_USER_ASSERTION" --supports-obo
323+
```
324+
325+
Expected delegation path output:
326+
327+
- `idp_obo_token_exchange` (if OBO succeeds), or
328+
- `authority_mandate_delegation` (fallback).
329+
330+
---
331+
281332
## Local identity registry + flush queue
282333

283334
Enable ephemeral task identity registry and local ledger queue:

examples/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,9 @@ PYTHONPATH=. python examples/authorityd/daemon_endpoint_check.py
4242
For Okta OBO/token-exchange compatibility setup and troubleshooting, see:
4343

4444
- `examples/README_Okta.md`
45+
46+
## Entra compatibility example notes
47+
48+
For Entra OBO compatibility setup and troubleshooting, see:
49+
50+
- `examples/README_Entra.md`

examples/README_Entra.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Entra Example Notes
2+
3+
This note covers running the Entra OBO compatibility demo and common setup outcomes.
4+
5+
## Prerequisites
6+
7+
- Populate `AgentIdentity/.env` with:
8+
- `ENTRA_TENANT_ID`
9+
- `ENTRA_CLIENT_ID`
10+
- `ENTRA_CLIENT_SECRET`
11+
- `ENTRA_SCOPE` (for example `api://predicate-authority/.default`)
12+
- optional for OBO path: `ENTRA_USER_ASSERTION`
13+
- Load env vars:
14+
15+
```bash
16+
set -a
17+
source .env
18+
set +a
19+
```
20+
21+
## Run Entra compatibility demo
22+
23+
```bash
24+
python examples/delegation/entra_obo_compat_demo.py \
25+
--tenant-id "$ENTRA_TENANT_ID" \
26+
--client-id "$ENTRA_CLIENT_ID" \
27+
--client-secret "$ENTRA_CLIENT_SECRET" \
28+
--scope "$ENTRA_SCOPE"
29+
```
30+
31+
If tenant supports OBO and you have a user assertion token:
32+
33+
```bash
34+
python examples/delegation/entra_obo_compat_demo.py \
35+
--tenant-id "$ENTRA_TENANT_ID" \
36+
--client-id "$ENTRA_CLIENT_ID" \
37+
--client-secret "$ENTRA_CLIENT_SECRET" \
38+
--scope "$ENTRA_SCOPE" \
39+
--user-assertion "$ENTRA_USER_ASSERTION" \
40+
--supports-obo
41+
```
42+
43+
## Common outcomes
44+
45+
- `obo_reason=tenant_capability_disabled`:
46+
- tenant/app is not configured for OBO, fallback delegation path should be used.
47+
- `obo_reason=user_assertion_required`:
48+
- OBO is requested but no user assertion was provided.
49+
- `unauthorized_client` from token endpoint:
50+
- app registration is not authorized for the requested OBO grant/policy.
51+
52+
## Compatibility behavior
53+
54+
- OBO success -> `delegation_path: idp_obo_token_exchange`.
55+
- OBO unavailable/not configured -> `delegation_path: authority_mandate_delegation`.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import json
5+
import os
6+
import sys
7+
from pathlib import Path
8+
9+
10+
def _ensure_repo_root_on_syspath() -> None:
11+
repo_root = Path(__file__).resolve().parents[2]
12+
root = str(repo_root)
13+
if root not in sys.path:
14+
sys.path.insert(0, root)
15+
16+
17+
def run(
18+
tenant_id: str,
19+
client_id: str,
20+
client_secret: str,
21+
scope: str,
22+
supports_obo: bool,
23+
user_assertion: str | None,
24+
authority_host: str,
25+
authority_scheme: str,
26+
timeout_s: float,
27+
) -> dict[str, object]:
28+
_ensure_repo_root_on_syspath()
29+
from predicate_authority import ( # pylint: disable=import-error
30+
EntraCompatibilityConfig,
31+
EntraTenantCapabilities,
32+
run_entra_obo_compatibility_check,
33+
)
34+
35+
result = run_entra_obo_compatibility_check(
36+
config=EntraCompatibilityConfig(
37+
tenant_id=tenant_id,
38+
client_id=client_id,
39+
client_secret=client_secret,
40+
scope=scope,
41+
authority_host=authority_host,
42+
authority_scheme=authority_scheme,
43+
),
44+
capabilities=EntraTenantCapabilities(supports_obo=supports_obo),
45+
user_assertion=user_assertion,
46+
timeout_s=timeout_s,
47+
)
48+
result["delegation_path"] = (
49+
"idp_obo_token_exchange"
50+
if bool(result.get("obo_ok", False))
51+
else "authority_mandate_delegation"
52+
)
53+
return result
54+
55+
56+
def main() -> None:
57+
parser = argparse.ArgumentParser(
58+
description="Entra OBO compatibility demo for delegation flow."
59+
)
60+
parser.add_argument("--tenant-id", default=os.getenv("ENTRA_TENANT_ID"))
61+
parser.add_argument("--client-id", default=os.getenv("ENTRA_CLIENT_ID"))
62+
parser.add_argument("--client-secret", default=os.getenv("ENTRA_CLIENT_SECRET"))
63+
parser.add_argument(
64+
"--scope", default=os.getenv("ENTRA_SCOPE", "api://predicate-authority/.default")
65+
)
66+
parser.add_argument("--user-assertion", default=os.getenv("ENTRA_USER_ASSERTION"))
67+
parser.add_argument(
68+
"--authority-host", default=os.getenv("ENTRA_AUTHORITY_HOST", "login.microsoftonline.com")
69+
)
70+
parser.add_argument("--authority-scheme", default=os.getenv("ENTRA_AUTHORITY_SCHEME", "https"))
71+
parser.add_argument("--timeout-s", type=float, default=5.0)
72+
parser.add_argument("--supports-obo", action="store_true")
73+
args = parser.parse_args()
74+
75+
missing = [
76+
name
77+
for name, value in (
78+
("tenant_id", args.tenant_id),
79+
("client_id", args.client_id),
80+
("client_secret", args.client_secret),
81+
("scope", args.scope),
82+
)
83+
if value is None or str(value).strip() == ""
84+
]
85+
if missing:
86+
raise SystemExit(f"Missing required arguments/env vars: {', '.join(missing)}")
87+
88+
payload = run(
89+
tenant_id=str(args.tenant_id),
90+
client_id=str(args.client_id),
91+
client_secret=str(args.client_secret),
92+
scope=str(args.scope),
93+
supports_obo=bool(args.supports_obo),
94+
user_assertion=(str(args.user_assertion) if args.user_assertion is not None else None),
95+
authority_host=str(args.authority_host),
96+
authority_scheme=str(args.authority_scheme),
97+
timeout_s=float(args.timeout_s),
98+
)
99+
print(json.dumps(payload, indent=2, sort_keys=True))
100+
101+
102+
if __name__ == "__main__":
103+
main()

0 commit comments

Comments
 (0)