Add two-tier RBAC: permission/role groups, access policies, group claim
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline was successful

Introduce a user -> role -> [permissions] model for app access and roles,
managed declaratively.

- Permission groups (akP-<app>-<access>) under config/permissions/: atomic units,
  each names the application it grants access to.
- Role groups (akR-<role>) under config/roles/: what users are assigned to;
  each nests permission groups via parents (akR-global-admin -> all *-admin,
  akR-standard-user -> all *-user). Split into a separate authentik_group
  resource so roles can reference permission ids without self-reference.
- Policy bindings gate each application to its permission groups (and, via
  child->parent membership propagation, the roles that nest them).
- Hierarchical `groups` scope mapping: walks user groups up through .parents so
  the OIDC claim includes inherited permission groups (works around
  goauthentik/authentik#15579). Inert until a provider requests the `groups`
  scope, so no behaviour change to existing apps until they opt in.

Validated with `tofu validate`.
This commit is contained in:
2026-07-18 16:11:03 +10:00
parent 55ba291531
commit 1dab2ecc6f
12 changed files with 147 additions and 11 deletions
+10
View File
@@ -12,6 +12,16 @@ locals {
trimsuffix(basename(file_path), ".yaml") => content
if startswith(file_path, "groups/")
}
permission_groups = {
for file_path, content in local.all_configs :
trimsuffix(basename(file_path), ".yaml") => content
if startswith(file_path, "permissions/")
}
role_groups = {
for file_path, content in local.all_configs :
trimsuffix(basename(file_path), ".yaml") => content
if startswith(file_path, "roles/")
}
providers_saml = {
for file_path, content in local.all_configs :
trimsuffix(basename(file_path), ".yaml") => content
@@ -0,0 +1,4 @@
# Permission: admin access to argocd. Bound to the argocd application for
# access, and mapped to the argocd admin role via the groups claim.
name: akP-argocd-admin
application: argocd
+4
View File
@@ -0,0 +1,4 @@
# Permission: user access to argocd. Bound to the argocd application for
# access, and mapped to the argocd user role via the groups claim.
name: akP-argocd-user
application: argocd
@@ -0,0 +1,4 @@
# Permission: admin access to grafana. Bound to the grafana application for
# access, and mapped to the grafana admin role via the groups claim.
name: akP-grafana-admin
application: grafana
@@ -0,0 +1,4 @@
# Permission: user access to grafana. Bound to the grafana application for
# access, and mapped to the grafana user role via the groups claim.
name: akP-grafana-user
application: grafana
@@ -0,0 +1,4 @@
# Permission: admin access to rancher. Bound to the rancher application for
# access, and mapped to the rancher admin role via the groups claim.
name: akP-rancher-admin
application: rancher
@@ -0,0 +1,4 @@
# Permission: user access to rancher. Bound to the rancher application for
# access, and mapped to the rancher user role via the groups claim.
name: akP-rancher-user
application: rancher
+7
View File
@@ -0,0 +1,7 @@
# Role: full admin across all onboarded apps. Assign users here for org-wide admin.
name: akR-global-admin
is_superuser: false
permissions:
- ak-p-grafana-admin
- ak-p-argocd-admin
- ak-p-rancher-admin
+6
View File
@@ -0,0 +1,6 @@
# Role: standard (non-admin) access across all onboarded apps.
name: akR-standard-user
permissions:
- ak-p-grafana-user
- ak-p-argocd-user
- ak-p-rancher-user
@@ -17,8 +17,10 @@ terraform {
}
inputs = {
groups = local.config.groups
providers_saml = local.config.providers_saml
providers_oauth2 = local.config.providers_oauth2
providers_ldap = local.config.providers_ldap
groups = local.config.groups
permission_groups = local.config.permission_groups
role_groups = local.config.role_groups
providers_saml = local.config.providers_saml
providers_oauth2 = local.config.providers_oauth2
providers_ldap = local.config.providers_ldap
}
+66 -7
View File
@@ -7,6 +7,50 @@ resource "authentik_group" "this" {
attributes = jsonencode(each.value.attributes)
}
# Permission groups (akP-*): atomic, leaf groups. Mapped to app roles via the
# groups claim and bound to applications for access.
resource "authentik_group" "permission" {
for_each = var.permission_groups
name = each.value.name
attributes = jsonencode(each.value.attributes)
}
# Role groups (akR-*): what users are assigned to. Each nests permission groups
# as parents, so a role member is an effective member of every permission it
# grants. Separate resource from permissions so this reference is not a
# self-reference (authentik_group cannot refer to itself).
resource "authentik_group" "role" {
for_each = var.role_groups
name = each.value.name
is_superuser = each.value.is_superuser
parents = [for p in each.value.permissions : authentik_group.permission[p].id]
attributes = jsonencode(each.value.attributes)
}
# Expand the OIDC `groups` claim to include inherited (ancestor) groups. The
# default profile mapping only emits direct groups (goauthentik/authentik#15579),
# so a member of a role group would not see the permission groups it nests. This
# walks each of the user's direct groups up through `.parents` and emits the full
# set of names, so role -> permission nesting drives in-app roles. Only evaluated
# when a provider requests the `groups` scope.
resource "authentik_property_mapping_provider_scope" "groups_hierarchical" {
name = "unkin: groups (hierarchical)"
scope_name = "groups"
expression = <<-EOT
groups = {}
pending = list(user.ak_groups.all())
while pending:
grp = pending.pop()
if grp.pk in groups:
continue
groups[grp.pk] = grp.name
pending += list(grp.parents.all())
return {"groups": sorted(groups.values())}
EOT
}
resource "authentik_provider_saml" "this" {
for_each = var.providers_saml
@@ -46,13 +90,16 @@ data "vault_kv_secret_v2" "oauth2" {
resource "authentik_provider_oauth2" "this" {
for_each = var.providers_oauth2
name = each.value.name
authorization_flow = data.authentik_flow.oauth2_authorization[each.key].id
invalidation_flow = data.authentik_flow.oauth2_invalidation[each.key].id
client_type = each.value.client_type
client_id = each.value.client_id
client_secret = each.value.client_secret_vault != null ? data.vault_kv_secret_v2.oauth2[each.key].data["client_secret"] : null
property_mappings = try(data.authentik_property_mapping_provider_scope.oauth2[each.key].ids, [])
name = each.value.name
authorization_flow = data.authentik_flow.oauth2_authorization[each.key].id
invalidation_flow = data.authentik_flow.oauth2_invalidation[each.key].id
client_type = each.value.client_type
client_id = each.value.client_id
client_secret = each.value.client_secret_vault != null ? data.vault_kv_secret_v2.oauth2[each.key].data["client_secret"] : null
property_mappings = concat(
try(data.authentik_property_mapping_provider_scope.oauth2[each.key].ids, []),
[authentik_property_mapping_provider_scope.groups_hierarchical.id],
)
signing_key = each.value.signing_key
access_token_validity = each.value.access_token_validity
allowed_redirect_uris = each.value.redirect_uris
@@ -105,3 +152,15 @@ resource "authentik_outpost" "ldap" {
type = "ldap"
protocol_providers = [authentik_provider_ldap.this[each.key].id]
}
# Gate application access: bind each permission group that names an `application`
# to that app. Authentik ORs bindings, and membership propagates from child
# groups, so a member of any role that nests the permission is also covered.
# With any binding present, only these groups (and their children) can authorize.
resource "authentik_policy_binding" "app_access" {
for_each = { for k, v in var.permission_groups : k => v if v.application != null }
target = authentik_application.oauth2[each.value.application].uuid
group = authentik_group.permission[each.key].id
order = 0
}
+28
View File
@@ -10,6 +10,34 @@ variable "groups" {
default = {}
}
# Two-tier RBAC. Permission groups (akP-*) are the atomic units mapped to app
# roles and bound to applications for access. Role groups (akR-*) are what users
# are assigned to; each nests permission groups via `parents`, so a member of a
# role is an effective member of every permission it grants (Authentik membership
# propagates child -> parent). Split into two variables/resources so roles can
# reference permission group ids without the authentik_group self-reference error.
variable "permission_groups" {
type = map(object({
name = string
# slug of the oauth2 application this permission grants *access* to; when set,
# a policy binding is created gating that app to this group (and its children).
application = optional(string, null)
attributes = optional(map(string), {})
}))
default = {}
}
variable "role_groups" {
type = map(object({
name = string
# keys into var.permission_groups that this role nests (becomes its parents).
permissions = optional(list(string), [])
is_superuser = optional(bool, false)
attributes = optional(map(string), {})
}))
default = {}
}
variable "providers_saml" {
type = map(object({
name = string