Skip to content

Commit d7869e9

Browse files
committed
feat(auth): add workload delegation primitives
Signed-off-by: Ryan S <267728323+ironcommit@users.noreply.github.com>
1 parent e32287b commit d7869e9

14 files changed

Lines changed: 1800 additions & 31 deletions

File tree

openapi/ga/individual/platform.openapi.yaml

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

openapi/ga/openapi.yaml

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

openapi/openapi.yaml

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/nmp_common/src/nmp/common/auth/__init__.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,23 @@
2424
from .models import NMP_PRINCIPAL_ENVVAR, AuthContext, Principal
2525
from .permissions import ALL_WORKSPACES, compute_accessible_workspaces
2626
from .tasks import principal_from_env
27+
from .workload_delegations import (
28+
DOCKER_OPAQUE_WORKLOAD_PROOF_TOKEN_TYPE,
29+
OPAQUE_DOCKER_PROOF_PREFIX,
30+
WORKLOAD_DELEGATION_ENTITY_TYPE,
31+
InvalidWorkloadProofTokenError,
32+
ParsedOpaqueDockerProofToken,
33+
WorkloadDelegationConflictError,
34+
WorkloadDelegationEntity,
35+
WorkloadDelegationError,
36+
WorkloadDelegationStore,
37+
WorkloadDelegationValidationError,
38+
create_opaque_docker_proof_token,
39+
docker_delegation_name,
40+
parse_opaque_docker_proof_token,
41+
reference_delegation_name,
42+
verify_opaque_docker_proof_token_hash,
43+
)
2744

2845
# Testing utilities are NOT exported here to avoid importing dev dependencies (respx)
2946
# at runtime. Import directly from nmp.common.auth.testing when needed in tests.
@@ -41,14 +58,29 @@
4158
"ACCESS_KEY_JWKS_PATH",
4259
"ACCESS_KEY_TOKEN_TYPE",
4360
"AccessKeyIssuerService",
61+
"DOCKER_OPAQUE_WORKLOAD_PROOF_TOKEN_TYPE",
62+
"OPAQUE_DOCKER_PROOF_PREFIX",
63+
"WORKLOAD_DELEGATION_ENTITY_TYPE",
64+
"InvalidWorkloadProofTokenError",
4465
"NMP_PRINCIPAL_ENVVAR",
66+
"ParsedOpaqueDockerProofToken",
4567
"Principal",
68+
"WorkloadDelegationConflictError",
69+
"WorkloadDelegationEntity",
70+
"WorkloadDelegationError",
71+
"WorkloadDelegationStore",
72+
"WorkloadDelegationValidationError",
4673
"auth_as_service",
4774
"auth_client_context",
4875
"build_service_principal_headers",
76+
"create_opaque_docker_proof_token",
77+
"docker_delegation_name",
4978
"principal_from_env",
79+
"parse_opaque_docker_proof_token",
80+
"reference_delegation_name",
5081
"compute_accessible_workspaces",
5182
"get_auth_client",
5283
"get_principal_auth_headers",
5384
"validate_access_key_token",
85+
"verify_opaque_docker_proof_token_hash",
5486
]

packages/nmp_common/src/nmp/common/auth/jwt.py

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@
2727
_DISCOVERY_CACHE_TTL = 3600 # 1 hour
2828

2929

30+
@dataclass
31+
class ActorClaims:
32+
"""Validated RFC 8693 actor claims."""
33+
34+
subject: str
35+
groups: list[str]
36+
37+
3038
@dataclass
3139
class TokenClaims:
3240
"""Validated token claims."""
@@ -36,6 +44,7 @@ class TokenClaims:
3644
groups: list[str]
3745
scopes: list[str]
3846
raw_claims: dict
47+
actor: Optional[ActorClaims] = None
3948

4049

4150
class UnsignedJWTRejectedError(Exception):
@@ -96,17 +105,11 @@ def _extract_token_claims(self, claims: dict) -> Optional[TokenClaims]:
96105
logger.warning("Token is missing a valid subject claim")
97106
return None
98107

99-
email = claims.get(self.config.oidc.email_claim)
108+
email_value = claims.get(self.config.oidc.email_claim)
109+
email = email_value if isinstance(email_value, str) else None
100110

101-
groups: list[str] = []
102-
for claim_name in [self.config.oidc.groups_claim, "cognito:groups"]:
103-
if claim_name in claims:
104-
groups_value = claims[claim_name]
105-
if isinstance(groups_value, str):
106-
groups = [g.strip() for g in groups_value.split(",")]
107-
elif isinstance(groups_value, list):
108-
groups = groups_value
109-
break
111+
groups = self._extract_groups_from_claims(claims)
112+
actor = self._extract_actor_claims(claims)
110113

111114
scopes: list[str] = []
112115
scope_value = claims.get("scope") or claims.get("scp")
@@ -130,6 +133,39 @@ def _extract_token_claims(self, claims: dict) -> Optional[TokenClaims]:
130133
groups=groups,
131134
scopes=scopes,
132135
raw_claims=claims,
136+
actor=actor,
137+
)
138+
139+
def _extract_groups_from_claims(self, claims: dict) -> list[str]:
140+
"""Extract normalized groups from configured or provider-specific claims."""
141+
for claim_name in [self.config.oidc.groups_claim, "cognito:groups"]:
142+
if claim_name not in claims:
143+
continue
144+
value = claims[claim_name]
145+
if isinstance(value, str):
146+
return [group.strip() for group in value.split(",") if group.strip()]
147+
if isinstance(value, list):
148+
return [str(group).strip() for group in value if str(group).strip()]
149+
return []
150+
return []
151+
152+
def _extract_actor_claims(self, claims: dict) -> Optional[ActorClaims]:
153+
"""Extract RFC 8693 act claims when a valid actor subject is present."""
154+
actor_claims = claims.get("act")
155+
if not isinstance(actor_claims, dict):
156+
return None
157+
158+
actor_subject = actor_claims.get("sub")
159+
if not isinstance(actor_subject, str):
160+
return None
161+
162+
actor_subject = actor_subject.strip()
163+
if not actor_subject:
164+
return None
165+
166+
return ActorClaims(
167+
subject=actor_subject,
168+
groups=self._extract_groups_from_claims(actor_claims),
133169
)
134170

135171
async def validate_token(self, token: str) -> Optional[TokenClaims]:

packages/nmp_common/src/nmp/common/auth/token_resolver.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ class ResolvedBearerToken:
2222

2323
@property
2424
def principal(self) -> Principal:
25+
if self.claims.actor is not None:
26+
return Principal(
27+
id=self.claims.actor.subject,
28+
groups=self.claims.actor.groups,
29+
on_behalf_of=self.claims.subject,
30+
on_behalf_of_email=self.claims.email,
31+
on_behalf_of_groups=self.claims.groups,
32+
)
2533
return Principal(
2634
id=self.claims.subject,
2735
email=self.claims.email,

0 commit comments

Comments
 (0)