Skip to main content

One post tagged with "opa"

View All Tags

RBAC with Dex IdP, envoy gateway and OPA

· 3 min read
Moazzem Hossen
building edge, yet another Postgres backend
package envoy.authz

import rego.v1

default allow := false

oidc_issuer := "https://c02aaf0593a5.eu-west1.edgeflare.dev/iam"
oidc_audience := "public-webui"
jwks_endpoint := "https://c02aaf0593a5.eu-west1.edgeflare.dev/iam/keys"

method := input.attributes.request.http.method
path := input.attributes.request.http.path

bearer_token := t if {
v := input.attributes.request.http.headers.authorization
startswith(v, "Bearer ")
t := substring(v, 7, -1)
}

public_paths := {"/health", "/metrics", "/ready"}

allow if { path in public_paths }

# ---------------------------------------------------------------------------
# Step 1: decode without verification to extract the kid from the header.
# This is safe — we only use the kid to fetch the right JWKS key.
# Actual verification happens in step 2.
# ---------------------------------------------------------------------------
unverified_header := io.jwt.decode(bearer_token)[0]

# ---------------------------------------------------------------------------
# Step 2: fetch JWKS, keyed on kid so cache is busted on key rotation.
# raw_body returns a string — exactly what io.jwt.verify_rs256 expects.
# force_cache + force_cache_duration_seconds is the correct OPA caching API.
# ---------------------------------------------------------------------------
jwks := http.send({
"method": "GET",
"url": concat("?", [
jwks_endpoint,
urlquery.encode_object({"kid": unverified_header.kid}),
]),
"force_cache": true,
"force_cache_duration_seconds": 3600,
"tls_use_system_certs": true,
}).raw_body

# ---------------------------------------------------------------------------
# Step 3: verify RS256 signature. Returns true/false — no claim checks here.
# ---------------------------------------------------------------------------
sig_valid if { io.jwt.verify_rs256(bearer_token, jwks) }

# ---------------------------------------------------------------------------
# Step 4: decode claims — only after signature is confirmed valid.
# Then check iss and aud manually (more explicit, handles string/array aud).
# ---------------------------------------------------------------------------
claims := io.jwt.decode(bearer_token)[1] if { sig_valid }

token_valid if {
sig_valid
claims.iss == oidc_issuer
claims.aud == oidc_audience # string — Dex issues aud as plain string
now := time.now_ns() / 1000000000
claims.exp > now
}

# ---------------------------------------------------------------------------
# Claim accessors — only defined when token is fully valid
# ---------------------------------------------------------------------------
sub := claims.sub if { token_valid }
email := claims.email if { token_valid }
groups := claims.groups if { token_valid }

# ---------------------------------------------------------------------------
# Group membership helper
# ---------------------------------------------------------------------------
user_in_group(group) if { group in groups }

# ---------------------------------------------------------------------------
# Role derivation — maps Dex groups → app roles.
# Lives here, not in the IdP. Swap group names without touching Dex config.
# ---------------------------------------------------------------------------
is_admin if user_in_group("platform-admins")
is_editor if user_in_group("engineering")
is_editor if user_in_group("product")
is_viewer if user_in_group("contractors")
is_viewer if user_in_group("readonly")

# ---------------------------------------------------------------------------
# Method sets per role
# ---------------------------------------------------------------------------
admin_methods := {"GET", "POST", "PUT", "DELETE", "PATCH"}
editor_methods := {"GET", "POST", "PUT", "PATCH"}
viewer_methods := {"GET"}

# ---------------------------------------------------------------------------
# /api/... — role-based
# ---------------------------------------------------------------------------
allow if { startswith(path, "/api/"); is_admin; method in admin_methods }
allow if { startswith(path, "/api/"); is_editor; method in editor_methods }
allow if { startswith(path, "/api/"); is_viewer; method in viewer_methods }

# ---------------------------------------------------------------------------
# /admin/... — platform-admins only
# ---------------------------------------------------------------------------
allow if {
startswith(path, "/admin/")
is_admin
}

# ---------------------------------------------------------------------------
# /deploy/... — engineering or sre, no contractors
# ---------------------------------------------------------------------------
allow if {
startswith(path, "/deploy/")
method in {"POST", "GET"}
not user_in_group("contractors")
user_in_group("engineering")
}

allow if {
startswith(path, "/deploy/")
method in {"POST", "GET"}
not user_in_group("contractors")
user_in_group("sre")
}

# ---------------------------------------------------------------------------
# /debug/... — sre only; /debug/prod is break-glass (named subs)
# ---------------------------------------------------------------------------
break_glass_subs := {
"CiQwODFkNGY5ZS1lYzM1LTQ0YmQtOWE2YS1hODVkNDA0Y2Q2ZDcSBWxvY2Fs", # alice
"CiQ3YjNkNGY5ZS1lYzM1LTQ0YmQtOWE2YS1hODVkNDA0Y2Q2ZDcSBWxvY2Fs", # bob
}

allow if {
startswith(path, "/debug/")
not startswith(path, "/debug/prod")
user_in_group("sre")
method == "GET"
}

allow if {
path == "/debug/prod"
user_in_group("sre")
sub in break_glass_subs
method == "GET"
}

# ---------------------------------------------------------------------------
# /internal/... — service accounts (no groups, identified by sub)
# ---------------------------------------------------------------------------
service_account_subs := {
"[email protected]",
"[email protected]",
}

allow if {
startswith(path, "/internal/")
sub in service_account_subs
method in {"GET", "POST"}
}

# ---------------------------------------------------------------------------
# Debug — remove after validating
# ---------------------------------------------------------------------------
debug := {
"bearer_present": bearer_token != "",
"kid": unverified_header.kid,
"sig_valid": sig_valid,
"token_valid": token_valid,
"claims_iss": claims.iss,
"claims_aud": claims.aud,
"claims_exp": claims.exp,
"groups": groups,
"is_admin": is_admin,
} if { bearer_token != "" }