Skip to main content

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 := {
"ci-pipeline@services.internal",
"monitoring@services.internal",
}

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 != "" }

Postgres on Angular with PGlite

· 4 min read
Moazzem Hossen
building edge, yet another Postgres backend

PGlite + Angular Signals offers an intriguing persistence and state-management solution, especially for PWAs requiring offline capabilities. When Postgres is already in the backend stack, this combo provides unified queries and seamless sync with remote Postgres.

Getting PGlite to work with Angular took some time, so I thought of documenting a minimal demo.

  1. Create a demo app and install @electric-sql/pglite