{
  "version": 1,
  "id": "multicloud-identity",
  "title": "Identity and Access Across Clouds",
  "summary": "Centralised identity, workload identity federation and how IAM evaluation differs between providers.",
  "estimatedMinutes": 15,
  "prerequisites": [],
  "source": "courses-notebook CLD302_multi_cloud_architecture 03_identity_and_access",
  "quiz": [
    {
      "prompt": "In IAM policy evaluation across the major clouds, which statement is correct?",
      "options": [
        "Explicit ALLOW always overrides explicit DENY",
        "Default is ALLOW unless explicitly denied",
        "Explicit DENY wins over any ALLOW; default is implicit DENY",
        "Conditions can override an explicit DENY"
      ],
      "answer": 2
    },
    {
      "prompt": "Workload Identity Federation primarily eliminates the need for:",
      "options": [
        "TLS certificates",
        "Long-lived API keys stored in CI/CD",
        "Cloud accounts entirely",
        "Multi-factor authentication for humans"
      ],
      "answer": 1
    },
    {
      "prompt": "Which is the recommended approach to centralized identity in a multi-cloud estate?",
      "options": [
        "One IAM user per cloud per employee with long-lived passwords",
        "One central IdP (Entra/Okta) federated into each cloud's STS via OIDC/SAML",
        "Share root credentials across teams",
        "Disable identity federation"
      ],
      "answer": 1
    }
  ],
  "lesson": "## 3.1  The Identity Problem in Multi-Cloud\n\nEach cloud has its own identity system. Without a federation strategy, you end up with:\n\n- Long-lived API keys shared across teams\n- Duplicate user accounts on every provider\n- No single source of truth for \"who has access to what\"\n- Manual revocation workflows when an employee leaves\n\n**The goal:** one identity plane that spans all clouds, with short-lived credentials and machine identities that require no stored secrets.\n\n## 3.2  Identity Systems by Provider\n\n| Concept | AWS | GCP | Azure |\n|---------|-----|-----|-------|\n| Human identity | IAM Users / SSO | Cloud Identity | Azure AD (Entra ID) |\n| Machine identity | IAM Roles (IRSA, EC2 profiles) | Service Accounts | Managed Identities |\n| Federation standard | SAML 2.0, OIDC | OIDC, SAML | SAML, OIDC, WS-Fed |\n| Cross-account access | IAM Role assume | Workload Identity Federation | Cross-tenant app registrations |\n| Policy language | JSON (IAM policies) | JSON (IAM bindings) | JSON (Azure RBAC) |\n\n## 3.3  Workload Identity Federation (No Stored Secrets)\n\nThe modern pattern for machine-to-cloud authentication:\n\n```\n1. Workload obtains short-lived token from its native identity provider\n   (GitHub Actions OIDC token, Kubernetes service account JWT)\n\n2. Workload presents token to target cloud's STS\n   (AWS STS AssumeRoleWithWebIdentity, GCP STS exchangeToken)\n\n3. Cloud validates token signature against trusted OIDC issuer\n\n4. Cloud returns short-lived cloud credentials (15 min\u20131 hr)\n\n5. Workload uses credentials to call cloud APIs\n   \u2192 No long-lived secret ever stored in CI/CD or source code\n```\n\n```python\n# IAM policy evaluator simulation\n# Models Allow/Deny evaluation logic matching AWS IAM semantics\n\nfrom dataclasses import dataclass, field\nfrom typing import Dict, List, Optional\nfrom enum import Enum\n\n\nclass Effect(str, Enum):\n    ALLOW = 'Allow'\n    DENY  = 'Deny'\n\n\n@dataclass\nclass PolicyStatement:\n    effect: Effect\n    actions: List[str]           # e.g. ['s3:GetObject', 's3:*']\n    resources: List[str]         # e.g. ['arn:aws:s3:::my-bucket/*']\n    conditions: Dict[str, str] = field(default_factory=dict)\n\n\n@dataclass\nclass Policy:\n    name: str\n    statements: List[PolicyStatement]\n\n\n@dataclass\nclass AccessRequest:\n    principal: str        # who is asking\n    action: str           # e.g. 's3:GetObject'\n    resource: str         # e.g. 'arn:aws:s3:::my-bucket/file.txt'\n    context: Dict[str, str] = field(default_factory=dict)\n\n\ndef _matches_pattern(pattern: str, value: str) -> bool:\n    \"\"\"Simple wildcard matching: '*' matches anything, '?' matches one char.\"\"\"\n    if pattern == '*':\n        return True\n    if '*' in pattern:\n        prefix, _, suffix = pattern.partition('*')\n        return value.startswith(prefix) and value.endswith(suffix)\n    return pattern == value\n\n\ndef evaluate_policies(request: AccessRequest, policies: List[Policy]) -> str:\n    \"\"\"\n    AWS IAM evaluation logic:\n    1. Default DENY\n    2. Explicit DENY wins over any ALLOW\n    3. Explicit ALLOW required to grant access\n    \"\"\"\n    has_allow = False\n\n    for policy in policies:\n        for stmt in policy.statements:\n            # Check if this statement applies\n            action_match   = any(_matches_pattern(a, request.action)   for a in stmt.actions)\n            resource_match = any(_matches_pattern(r, request.resource) for r in stmt.resources)\n\n            if not (action_match and resource_match):\n                continue\n\n            # Check conditions\n            conditions_met = all(\n                request.context.get(k) == v\n                for k, v in stmt.conditions.items()\n            )\n            if not conditions_met:\n                continue\n\n            if stmt.effect == Effect.DENY:\n                return f'DENY (explicit deny in policy: {policy.name})'\n            elif stmt.effect == Effect.ALLOW:\n                has_allow = True\n\n    if has_allow:\n        return 'ALLOW'\n    return 'DENY (no matching allow statement \u2014 implicit deny)'\n\n\n# \u2500\u2500 Example policies \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nread_only_s3 = Policy('ReadOnlyS3', [\n    PolicyStatement(\n        effect=Effect.ALLOW,\n        actions=['s3:GetObject', 's3:ListBucket'],\n        resources=['arn:aws:s3:::data-lake/*', 'arn:aws:s3:::data-lake'],\n    ),\n])\n\ndeny_pii_bucket = Policy('DenyPIIAccess', [\n    PolicyStatement(\n        effect=Effect.DENY,\n        actions=['s3:*'],\n        resources=['arn:aws:s3:::pii-data/*'],\n    ),\n])\n\nmfa_required_delete = Policy('MFARequiredForDelete', [\n    PolicyStatement(\n        effect=Effect.ALLOW,\n        actions=['s3:DeleteObject'],\n        resources=['arn:aws:s3:::data-lake/*'],\n        conditions={'aws:MultiFactorAuthPresent': 'true'},\n    ),\n])\n\nall_policies = [read_only_s3, deny_pii_bucket, mfa_required_delete]\n\ntest_requests = [\n    AccessRequest('analyst-role', 's3:GetObject',   'arn:aws:s3:::data-lake/report.parquet'),\n    AccessRequest('analyst-role', 's3:GetObject',   'arn:aws:s3:::pii-data/customers.csv'),\n    AccessRequest('analyst-role', 's3:PutObject',   'arn:aws:s3:::data-lake/output.csv'),\n    AccessRequest('analyst-role', 's3:DeleteObject','arn:aws:s3:::data-lake/old.parquet'),\n    AccessRequest('analyst-role', 's3:DeleteObject','arn:aws:s3:::data-lake/old.parquet',\n                  context={'aws:MultiFactorAuthPresent': 'true'}),\n]\n\nprint(f\"{'Principal':<20} {'Action':<20} {'Resource (short)':<30} Result\")\nprint('-' * 100)\nfor req in test_requests:\n    result = evaluate_policies(req, all_policies)\n    res_short = req.resource.split(':::')[-1]\n    mfa = ' [MFA]' if req.context.get('aws:MultiFactorAuthPresent') == 'true' else ''\n    print(f\"{req.principal:<20} {req.action:<20} {res_short:<30} {result}{mfa}\")\n```\n\n## 3.4  Cross-Cloud IAM Patterns\n\n| Pattern | How it works | Best for |\n|---------|-------------|----------|\n| **Centralised IdP** | Azure AD or Okta federates to AWS IAM Identity Center and GCP Cloud Identity | Enterprises with existing IdP investment |\n| **Workload Identity Federation** | Workload's native JWT exchanged for cloud credentials via OIDC | CI/CD pipelines, Kubernetes workloads |\n| **Cross-account roles** | AWS role trusts GCP service account; GCP SA impersonates AWS role | AWS\u2194GCP direct data access |\n| **Service mesh mTLS** | Mutual TLS between services; identity is the certificate | Microservice-to-microservice within a mesh |\n\n**Golden rule for machine identities:** Never store a long-lived secret. If you cannot avoid it, rotate it every 24 hours and alert on any access older than the rotation window.\n\n```python\n# Workload Identity Federation simulator\n# Models token exchange flow: native JWT \u2192 cloud STS \u2192 short-lived credentials\n\nimport hashlib\nimport time\nfrom dataclasses import dataclass\nfrom typing import Dict, Optional\n\n\n@dataclass\nclass OIDCToken:\n    \"\"\"Simplified OIDC JWT claims.\"\"\"\n    issuer: str          # e.g. 'https://token.actions.githubusercontent.com'\n    subject: str         # e.g. 'repo:acme/backend:ref:refs/heads/main'\n    audience: str        # e.g. 'https://iam.googleapis.com/...'\n    issued_at: float\n    expiry: float\n\n    def is_valid(self) -> bool:\n        return time.time() < self.expiry\n\n\n@dataclass\nclass CloudCredentials:\n    provider: str\n    access_key_id: str\n    secret: str          # short-lived; in real life this is ephemeral\n    expiry: float\n    principal_arn: str\n\n    def is_valid(self) -> bool:\n        return time.time() < self.expiry\n\n\nclass WorkloadIdentityPool:\n    \"\"\"\n    Simulates GCP Workload Identity Pool or AWS IAM OIDC provider.\n    Validates an OIDC token and issues short-lived cloud credentials.\n    \"\"\"\n    def __init__(self, provider: str, trusted_issuers: Dict[str, str]):\n        self.provider = provider\n        # issuer_url \u2192 mapped_role/service_account\n        self.trusted_issuers = trusted_issuers\n        self.ttl_seconds = 3600  # 1-hour credentials\n\n    def exchange(self, token: OIDCToken) -> Optional[CloudCredentials]:\n        if not token.is_valid():\n            print(f'  [STS] REJECTED: token expired')\n            return None\n\n        mapped_principal = self.trusted_issuers.get(token.issuer)\n        if not mapped_principal:\n            print(f'  [STS] REJECTED: issuer not trusted: {token.issuer}')\n            return None\n\n        # Derive a deterministic-but-fake access key for simulation\n        raw = f'{token.subject}:{time.time():.0f}'\n        key_id = hashlib.sha256(raw.encode()).hexdigest()[:16].upper()\n        secret = hashlib.sha256((raw + 'secret').encode()).hexdigest()[:32]\n\n        creds = CloudCredentials(\n            provider=self.provider,\n            access_key_id=key_id,\n            secret=secret,\n            expiry=time.time() + self.ttl_seconds,\n            principal_arn=mapped_principal,\n        )\n        print(f'  [STS] ISSUED credentials for principal: {mapped_principal}')\n        return creds\n\n\n# \u2500\u2500 Simulate a GitHub Actions workflow authenticating to AWS and GCP \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\ngithub_token = OIDCToken(\n    issuer='https://token.actions.githubusercontent.com',\n    subject='repo:acme/backend:ref:refs/heads/main',\n    audience='sts.amazonaws.com',\n    issued_at=time.time(),\n    expiry=time.time() + 300,  # 5-minute OIDC token\n)\n\naws_pool = WorkloadIdentityPool(\n    provider='aws',\n    trusted_issuers={\n        'https://token.actions.githubusercontent.com': 'arn:aws:iam::123456789:role/github-ci-role',\n    },\n)\n\ngcp_pool = WorkloadIdentityPool(\n    provider='gcp',\n    trusted_issuers={\n        'https://token.actions.githubusercontent.com': 'projects/acme/serviceAccounts/github-ci@acme.iam.gserviceaccount.com',\n    },\n)\n\nprint('=== Workload Identity Federation: GitHub Actions \u2192 AWS + GCP ===')\nprint(f'GitHub OIDC token issuer : {github_token.issuer}')\nprint(f'GitHub OIDC token subject: {github_token.subject}')\nprint()\n\nprint('Authenticating to AWS...')\naws_creds = aws_pool.exchange(github_token)\nif aws_creds:\n    print(f'  Access key (first 8): {aws_creds.access_key_id[:8]}...')\n    print(f'  Expires in: {int(aws_creds.expiry - time.time())} seconds')\n\nprint()\nprint('Authenticating to GCP...')\ngcp_creds = gcp_pool.exchange(github_token)\nif gcp_creds:\n    print(f'  Access key (first 8): {gcp_creds.access_key_id[:8]}...')\n    print(f'  Principal: {gcp_creds.principal_arn}')\n\nprint()\nprint('Testing expired token...')\nexpired_token = OIDCToken(\n    issuer='https://token.actions.githubusercontent.com',\n    subject='repo:acme/backend:ref:refs/heads/main',\n    audience='sts.amazonaws.com',\n    issued_at=time.time() - 400,\n    expiry=time.time() - 100,  # already expired\n)\naws_pool.exchange(expired_token)\n```\n\n## Summary\n\n1. **Federate, don't duplicate** \u2014 A centralised IdP (Azure AD, Okta) is always cheaper to manage than parallel user directories on each cloud.\n2. **Workload Identity Federation** eliminates long-lived secrets from CI/CD \u2014 every cloud supports OIDC token exchange from GitHub Actions, GitLab, Kubernetes, and other issuers.\n3. **Explicit Deny wins** \u2014 AWS, GCP, and Azure all evaluate policies with the same hierarchy: explicit deny beats explicit allow beats implicit deny.\n4. **Least privilege is a process** \u2014 Set boundaries tightly at provisioning time; review and shrink permissions quarterly using Access Analyzer (AWS) or Policy Insights (GCP).\n\n**Next \u2192** [`04_data_portability`](04_data_portability.ipynb) \u2014 open formats and cloud-agnostic storage.\n"
}
