Add two-tier RBAC: permission/role groups, access policies, group claim
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,46 @@
|
||||
# This file is maintained automatically by "tofu init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/goauthentik/authentik" {
|
||||
version = "2026.5.0"
|
||||
constraints = ">= 2026.5.0"
|
||||
hashes = [
|
||||
"h1:SeznjPKBzSrgo8WasRnuxiGMDSeQHEKsv3U/xw8bhQE=",
|
||||
"zh:0dc1706f6fbff866f4a96de56a4934b9a277954bcdd0713549a29a9b8ec85153",
|
||||
"zh:218417ec4e864f2d7e585d6c08d39bccb96d8f3bca16c6f762be15365e434234",
|
||||
"zh:24f9afa7a1174316da3478811848cd76ef348d8a983310b8d75ed6f45abe1a92",
|
||||
"zh:560092e47cb8a72b890b3eeafe1803202cd25cf27f5f5a6e2c370f645f5d86ae",
|
||||
"zh:5bc69d8de198007ad1587e146f98cffacf0d1a571800da549b308ff5f4541474",
|
||||
"zh:65248dce941472ad2a30d0754d2f3c2db6bb6fe5080946316fb097d6ba7cc79f",
|
||||
"zh:79c9a59a8d3c60280e27a064668889594da44c60f940b046b7c8e63be01067d0",
|
||||
"zh:87f26cadcd842d6e6d0af94ef0e56860557f5d07f487b10d69d38b63af68bea5",
|
||||
"zh:8e42c9d0e77d61cc2e5f8c8b761f6e484774d93771927b4cb5fbdae41209dd33",
|
||||
"zh:94ff632b9b4841527c6b652d51a850a8a47c84c0308a3efc189e0ff7e2558f87",
|
||||
"zh:b8d32d9f17a905b63c87a23306c02c295b7c8b70f72950071aa3086396932816",
|
||||
"zh:c91982af99474fc2e4e69be36ed3a68847f261963ed79f6a546fc75703992f99",
|
||||
"zh:eb9c1fd3020cf61e9b7a6a38d2965f4b521495a9928705e963459a4af857f97d",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.opentofu.org/hashicorp/vault" {
|
||||
version = "5.10.1"
|
||||
constraints = ">= 4.0.0"
|
||||
hashes = [
|
||||
"h1:wo5cTkl/1nlxMfdn1yEDIHNoRLMczuK6COH2Id4/zeY=",
|
||||
"zh:0abf976c01f0c0732d0ccc6481e52008be5ee9c8e3d9b5eba0573c640fcf7019",
|
||||
"zh:2aff4d7ee7ba9eb3de2cd5cda16ba92b4ec7a2b43232aec180984241a323b216",
|
||||
"zh:2cc186fd0bfc44e100a22b0b40ae8ddcd0ec210a53c1da65d310ee758b1d2b08",
|
||||
"zh:3f8fb8594736b34af4b26437dd4df4dd4042ad4905223995cfebb8a1f10682ec",
|
||||
"zh:47fb41b18b74073f557dbcd6aad2183e416293405ccd70c0691a279cfe97f8cd",
|
||||
"zh:517e2f2764d671c22d22def0384fdfc521b456458189189c0363375495d114dc",
|
||||
"zh:5a49a2003636f2b8a547d494a6c06d43d62a68299775305408c52eff22b1c11f",
|
||||
"zh:66d4e716920ada84b0c768f4aca4c8948388995462923349a01bd3818d82b618",
|
||||
"zh:7599f652e89a3f18fa4b76a59d115cc63255cc36ce6b273850509ba25031abca",
|
||||
"zh:9c3e38ae7e670de973b6255d7050f526cd2b3ca7c383d7ba7226fc204d97c507",
|
||||
"zh:d04b046023fa9fd69def678f27e001c298ea34fc99ba51f835cda82e496fdb57",
|
||||
"zh:d9acd8810f6660cd51bb4c25596632984ae18e93340c82a102d074c6eac95151",
|
||||
"zh:e161bcb9a22607270b980eeff2ba693335fb62d6978516dff93dd4c91cda99b3",
|
||||
"zh:ef47502f08cfcb5311b7b16a7905e0052bf28359e07cc00ed080ef454e0946cf",
|
||||
"zh:f0640ddb52e7e90c5006ff571f6ad0554e593665320c764c57a3d8b7ec31b490",
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user