Skip to content

Commit a7b2d03

Browse files
committed
Entra ID support
1 parent 6cfd458 commit a7b2d03

11 files changed

Lines changed: 696 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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,17 @@ 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+
134145
## Operations CLI
135146

136147
`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()

predicate_authority/README.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ It binds identity, policy, and runtime evidence so risky actions are authorized
55
before execution and denied fail-closed when checks do not pass.
66

77
Docs: https://www.PredicateSystems.ai/docs
8+
Github Repo: https://github.com/PredicateSystems/predicate-authority
89

910
Core pieces:
1011

@@ -15,3 +16,58 @@ Core pieces:
1516
- typed integration adapters (including `sdk-python` mapping helpers),
1617
- control-plane client primitives for shipping proof and usage batches to hosted APIs,
1718
- local identity registry primitives (ephemeral task identities + local flush queue).
19+
20+
## Quick usage example
21+
22+
```python
23+
from predicate_authority import ActionGuard, InMemoryProofLedger, LocalMandateSigner, PolicyEngine
24+
from predicate_contracts import (
25+
ActionRequest,
26+
ActionSpec,
27+
PolicyEffect,
28+
PolicyRule,
29+
PrincipalRef,
30+
StateEvidence,
31+
VerificationEvidence,
32+
)
33+
34+
guard = ActionGuard(
35+
policy_engine=PolicyEngine(
36+
rules=(
37+
PolicyRule(
38+
name="allow-orders",
39+
effect=PolicyEffect.ALLOW,
40+
principals=("agent:orders",),
41+
actions=("http.post",),
42+
resources=("https://api.vendor.com/orders",),
43+
),
44+
)
45+
),
46+
mandate_signer=LocalMandateSigner(secret_key="replace-with-strong-secret"),
47+
proof_ledger=InMemoryProofLedger(),
48+
)
49+
50+
request = ActionRequest(
51+
principal=PrincipalRef(principal_id="agent:orders", tenant_id="tenant-a"),
52+
action_spec=ActionSpec(
53+
action="http.post",
54+
resource="https://api.vendor.com/orders",
55+
intent="create order",
56+
),
57+
state_evidence=StateEvidence(source="backend", state_hash="sha256:example"),
58+
verification_evidence=VerificationEvidence(),
59+
)
60+
61+
decision = guard.authorize(request)
62+
print("allowed=", decision.allowed, "reason=", decision.reason.value)
63+
```
64+
65+
## Entra compatibility demo (capability-gated OBO)
66+
67+
```bash
68+
python examples/delegation/entra_obo_compat_demo.py \
69+
--tenant-id "$ENTRA_TENANT_ID" \
70+
--client-id "$ENTRA_CLIENT_ID" \
71+
--client-secret "$ENTRA_CLIENT_SECRET" \
72+
--scope "${ENTRA_SCOPE:-api://predicate-authority/.default}"
73+
```

0 commit comments

Comments
 (0)