Merge pull request 'Add two-tier RBAC: permission/role groups, access policies, hierarchical group claim' (#7) from benvin/rbac-groups into main
ci/woodpecker/push/apply Pipeline was successful

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-07-19 02:13:28 +10:00
12 changed files with 138 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
+3
View File
@@ -0,0 +1,3 @@
# Permission group akP-argocd-admin (name = filename). Grants admin
# access to argocd: bound to the argocd application and mapped to its admin role.
application: argocd
+3
View File
@@ -0,0 +1,3 @@
# Permission group akP-argocd-user (name = filename). Grants user
# access to argocd: bound to the argocd application and mapped to its user role.
application: argocd
@@ -0,0 +1,3 @@
# Permission group akP-grafana-admin (name = filename). Grants admin
# access to grafana: bound to the grafana application and mapped to its admin role.
application: grafana
+3
View File
@@ -0,0 +1,3 @@
# Permission group akP-grafana-user (name = filename). Grants user
# access to grafana: bound to the grafana application and mapped to its user role.
application: grafana
@@ -0,0 +1,3 @@
# Permission group akP-rancher-admin (name = filename). Grants admin
# access to rancher: bound to the rancher application and mapped to its admin role.
application: rancher
+3
View File
@@ -0,0 +1,3 @@
# Permission group akP-rancher-user (name = filename). Grants user
# access to rancher: bound to the rancher application and mapped to its user role.
application: rancher
+5
View File
@@ -0,0 +1,5 @@
# Role akR-global-admin (name = filename): full admin across all onboarded apps.
permissions:
- akP-grafana-admin
- akP-argocd-admin
- akP-rancher-admin
+5
View File
@@ -0,0 +1,5 @@
# Role akR-standard-user (name = filename): standard (non-admin) access everywhere.
permissions:
- akP-grafana-user
- akP-argocd-user
- akP-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
}
+67 -7
View File
@@ -7,6 +7,51 @@ 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.key
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.key
is_superuser = each.value.is_superuser
parents = [for p in each.value.permissions : authentik_group.permission[p].id]
attributes = jsonencode(each.value.attributes)
}
# Emit an `ak_groups` claim containing the user's groups AND all inherited
# (ancestor) groups, so role -> permission nesting reaches apps. The default
# profile mapping only emits *direct* groups under `groups`
# (goauthentik/authentik#15579); we use a distinct claim key so there is no
# collision with that (Authentik dict-overrides same-key claims in an
# unpredictable order). Apps request the `ak_groups` scope and read the
# `ak_groups` claim. Walks each direct group up through `.parents`.
resource "authentik_property_mapping_provider_scope" "groups_hierarchical" {
name = "unkin: ak_groups (hierarchical)"
scope_name = "ak_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 {"ak_groups": sorted(groups.values())}
EOT
}
resource "authentik_provider_saml" "this" {
for_each = var.providers_saml
@@ -46,13 +91,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 +153,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
}
+27
View File
@@ -10,6 +10,33 @@ 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.
# The group name is the map key (the config filename); no `name` field needed.
variable "permission_groups" {
type = map(object({
# 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({
# 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