Skip to content

Roles, users and groups

Three families, each with a block keyword of its own, each with a hard-coded attribute set rather than an open schema. None of them use the CI machinery that resources use: no class defaults are elided, no unknown-attribute warning is raised, and no b64() ever appears.

role "deploy-manager" {
  name        = "Deploy Manager"
  description = "Runs deployments"
  mailbox     = "[email protected]"

  grant {
    action = "action.job.create"
    bounds {
      bl = "PROD"
    }
  }

  grant {
    action = "action.job.delete"
    deny   = true
  }
}

user "alice" {
  email    = "[email protected]"
  realname = "Alice Foo"
  groups   = [group.developers]
}

user "bob" {
  grant {
    id_role = role.deploy-manager
    type    = "project"
    mid     = [project.bar, project.baz]
  }
}

group "developers" {
  name        = "Developers"
  description = "Dev team"

  grant {
    id_role = role.deploy-manager
    type    = "project"
    mid     = project.bar
  }
}

project "bar" {}

project "baz" {}

Alice is in a group and takes her permissions from it; Bob is in none and has his own. A user is written one way or the other, never both — see Groups or grants.

role

Address role.<slug>, slugged from the role's name. Stored in the role collection; the identity field is role.

Block attributes

Attribute Type Required Default Notes
(label) slug yes — HCL202 — the address
name string no the label written only when the real name does not slug to the label
description string no — written only when non-empty; a heredoc if it contains newlines
mailbox string no — written only when non-empty
grant { } block no — repeatable, one per permission

That is the complete set. Any other attribute on a role block decodes without complaint and is then discarded — the apply step passes exactly role, description, mailbox and actions to Baseliner::Model::Role. There is no warning for this.

Any nested block other than grant is silently discarded too.

grant

Attribute Type Required Default Notes
action string in practice yes — an action registry key, e.g. "action.job.create"
deny bool no false written only when true; stored as _deny
bounds { } block no — repeatable; each block is one scope

grant takes no labels. A grant with no bounds is unbounded — the permission applies everywhere.

role "operador" {
  grant {
    action = "action.job.rollback"
    bounds {
      bl = "PROD"
    }
    bounds {
      bl = "PREP"
    }
  }

  grant {
    action = "action.dashboards.view"
  }
}

Two bounds blocks under one grant mean "either", not "both".

bounds

The key set is open: it is whatever the action's registration declares. To find the keys an action accepts, read the bounds array in its register block — each entry has a key.

Key Value written as Meaning
deny bool true the bound itself is a denial; stored as _deny, written first
bl string environment name, e.g. "PROD" — a name, not an address
project reference project.foo
id_category reference category.change
id_status reference status.nuevo
id_repo reference resource.ArtifactLocal.public, GitRepository.core
id_field string a fieldlet id, e.g. "target_environment"
user_role reference role.deploy-manager
collection string a CI class name, e.g. "generic_server"
report string a report key, e.g. "report.clarive.jobs"

Keys inside a bounds block are written in alphabetical order, except deny which comes first.

role "jefe" {
  grant {
    action = "action.topicsfield.write"
    bounds {
      id_category = category.change
      id_field    = "target_environment"
      id_status   = status.nuevo
    }
  }

  grant {
    action = "action.ci.admin"
    bounds {
      collection = "generic_server"
    }
    bounds {
      collection = "project"
    }
  }
}

Any bound value holding a database id that cannot be resolved to an address is refused: the key is not written at all, and the export reports HCL106 Refused to write 14 for id_category: it is a database id and no address could be found for it. A grant that loses a bound this way is narrower on the target than it was on the source, so the export summary is worth reading.

bounds written as an attribute rather than a block is accepted and passed straight through:

grant {
  action = "action.x"
  bounds = [
    {
      bl = "PROD"
    },
  ]
}

This is undocumented behaviour that happens to work because the attribute loop runs first. Prefer the block form; only the block form is ever produced.

user

Address user.<slug>, slugged from the username. Stored in master_doc with collection = "user"; the identity field is username.

Block attributes

Attribute Type Required Default Notes
(label) slug yes — HCL202 — the address
username string no the label written only when the real username does not slug to the label
email string no — written only when non-empty
realname string no — written only when non-empty
active bool no true only ever written as false; absence means active
groups list of references no [] [group.developers]; the user's UserGroups
grant { } block no — repeatable, one per role and scope type; only on a user in no groups

Those six are everything cla export writes for a user. No name, no preferences, no credentials, in any --secret mode.

user "bar" {}

user "foo" {
  email = "[email protected]"
}

user "baz" {
  username = "BAZ"
  email    = "[email protected]"
  realname = "Baz Service Account"
  active   = false
}

Groups or grants

A user in one or more UserGroups does not have permissions of its own. Every time the user is saved, Clarive merges the project_security of its groups into the user's, and every time a group's permissions change it recomputes every member. What is stored on such a user is a copy, and writing it out would record the result rather than the cause.

So a user is written in exactly one of two ways:

  • In groups — a groups list and no grant blocks. The permissions come from the groups' own grant blocks.
  • In no groups — no groups attribute, and one grant block per role and scope type.
user "foo" {
  email  = "[email protected]"
  groups = [group.auditors, group.developers]
}

user "bar" {
  grant {
    id_role = role.developer
    type    = "project"
    mid     = [project.acme, project.baz]
  }
}

groups is a list of group.<slug> references, written sorted. A literal is HCL206 Attribute groups expects references, not a literal, and a reference to anything but a group — project.foo, say — is HCL223 Attribute groups lists user groups, and project.foo is not one. Duplicates are dropped.

Membership is written on the user and only there. A members attribute on a group block is an error — see group.

A user block that has both groups and grant blocks imports, but the grants are dropped with HCL219 Ignored the grants of user foo: a user in groups takes its grants from them. Applying them would be pointless: the first save of the user replaces them with its groups' permissions.

What an import does

groups is always applied, and absence means "in no groups":

The file has The import
groups = [...] puts the user in exactly those groups, taking it out of any others, and recomputes its permissions from them
no groups, and grant blocks takes the user out of every group and writes the grants as its own
no groups, no grant blocks takes the user out of every group and leaves it with no permissions

Membership is applied in the second pass, once every group in the file exists. The user is updated and then saved, which is what makes Clarive merge the groups' permissions into it — the same two steps the user administration screen takes.

Taking a user out of its groups hands it the file's grants, not the ones it had before it joined: Clarive does not keep them.

Credentials are never imported

user "foo" {
  email    = "[email protected]"
  password = "hunter2"
  api_key  = "k"
}

HCL204: Ignored 'password' on a user block: imports never touch credentials HCL204: Ignored 'api_key' on a user block: imports never touch credentials

A warning, not an error: the import proceeds and the other attributes are written. The refused list is an exact match on seven names:

password  md5  api_key  apikey  avatar  salt  token

Note that this is an exact list, not the sensitivity regex resources use. A name the regex would catch but this list does not — secret, private_key, passwd, webhook_url — is not refused on a user block: it decodes as an ordinary attribute and reaches the user document.

The second half of the guard is that resource "user" "bob" is refused with HCL214, so the second-order spelling cannot route around the credential check. See Addresses and references.

grant on a user or a group

Users and groups hold permissions the same way: a list of project_security rows, each granting one role over one scope. A grant block is flat: no action, no bounds blocks. A bounds block inside one is silently ignored.

The rows are written combined: one grant block per role and scope type, with every scope it covers in a single mid list. A single scope is written as a plain reference rather than a one-item list.

Key Type Meaning
id_role reference the role being granted
role reference alternate spelling of the same thing
type string the scope kind: "project", "area", "group", ...
mid reference, or list of references what the grant applies to

The keys other than mid are written in alphabetical order, and mid comes last. The blocks are ordered by what those keys read as, and the mid list by address, so an export is stable from one run to the next. The key set is open — whatever the stored row carries is written. In particular mid is not treated as bookkeeping here, even though mid is bookkeeping everywhere else: dropping it left every imported user with permissions pointing at nothing.

user "foo" {
  email    = "[email protected]"
  realname = "Foo Bar"

  grant {
    id_role = role.developer
    type    = "project"
    mid     = [project.acme, project.bar, project.baz]
  }

  grant {
    id_role = role.operador
    type    = "project"
    mid     = project.acme
  }
}

On import a mid list is expanded back into one row per scope. One block per row, with a single mid each, reads exactly the same, as does a scope repeated across blocks — the rows are de-duplicated and compared as a set, so the way a file arranges its grants never shows up as a change in the plan.

A scope whose database id has no address is left out of the mid list with HCL106, and the note is written as a comment on the block. If no scope in a block can be written, the whole block is dropped: a grant with no mid would widen rather than narrow the permission.

Writing no grant blocks at all removes every grant — an empty project_security list is written deliberately, so an import that removes every grant actually removes them.

What import accepts but export never writes

The decoder does not filter a user block against the class schema. Every attribute that is not username and not a credential lands in the model and is passed to the user CI. So phone, alias, timezone_pref, account_type and the rest of the class are all settable from a file even though no export will ever produce them:

user "foo" {
  email         = "[email protected]"
  phone         = "+34 000 000 000"
  account_type  = "system"
  language_pref = "es"
}

name is accepted and then dropped — a user's identity is the label or username, full stop.

The complete class attribute set:

user — class attributes

Attribute Type Default Notes
account_type regular | system regular
active Bool 1 exported only when false
alias Any —
api_key Any — sensitive, never exported, refused on import
autotab_mode Str same
bl BL *
country Str es
currency Str EUR
dashboard Any —
dashboards HashRef —
date_format_pref Str format_from_local
decimal Str Comma
description Maybe[Str] —
email Any — exported
groups CIs — exported as groups; stored as group_user relationships from each UserGroup
invited Bool 0
job Baseliner::Role::JobRunner — runtime, never stored
language_pref Any en
languages ArrayRef — list
md5 Str — refused on import
moniker Maybe[Str] —
name Maybe[Str] — accepted on import and discarded
password Any — sensitive, never exported, refused on import
phone Any —
project_security Any — written from grant blocks on a user in no groups; merged from its groups otherwise
realname Any — exported
repl HashRef —
resource_security CI — reference
show_empty_fields_pref Str 1
time_column Str total_exec
time_format_pref Str format_from_local
timezone_pref Str server_timezone
tour HashRef —
ts_in_activity Str 0
whats_new_disabled Maybe[Int] 0
whats_new_last_seen Maybe[Str] —
workspaces HashRef —

avatar, salt and token are not declared on the class but are in the refused-on-import list anyway.

group

Address group.<slug>, slugged from the group's name. Stored in master_doc with collection = "UserGroup"; the identity field is name.

Block attributes

Attribute Type Required Default Notes
(label) slug yes — HCL202 — the address
name string no the label written only when the real name does not slug to the label
description string no — written only when non-empty
grant { } block no — repeatable, one per role and scope type

A group's grant blocks are the permissions it hands its members, in the same combined form a user's are — see grant on a user or a group. Writing none removes every one of them.

group "developers" {
  name        = "Developers"
  description = "Dev team"

  grant {
    id_role = role.developer
    type    = "project"
    mid     = [project.acme, project.bar]
  }
}

A group block does not list its members: each member names the group in its own groups attribute (Groups or grants). A members attribute is HCL222 Write group membership on each user, as groups = [...], not as 'members' on the group, and the import stops. One canonical side means an import can never be handed two disagreeing halves of the same fact.

When an import changes a group, it recomputes the permissions of every member through the group CI's set_users, as the group administration screen does. Saving the group alone would leave each member holding a stale copy.

UserGroup — class attributes

Attribute Type Default Notes
active Bool 1
bl BL *
description Maybe[Str] — exported
groupname Any —
job Baseliner::Role::JobRunner — runtime, never stored
moniker Maybe[Str] —
name Maybe[Str] — exported when it does not slug to the label
project_security Any — written from grant blocks
resource_security CI — reference

As with users, the decoder does not filter against the class, so any of these can be set from a file even though only three, and the grants, are ever exported.

How they are written

Family Create / update Delete Second pass
role Baseliner::Model::Role->insert / ->update ->delete none
user ci->user->new(...)->save / ->update ->delete ->update(groups => [...]) then ->save, which merges the groups' permissions; the file's grants instead when there are no groups
group the UserGroup CI, then set_users on an update ->delete none

Grants are compared as a single unit in the plan. The live side is normalised by encoding the stored object and decoding it straight back, so a role's actions array and an incoming file's grant blocks are compared in exactly the same shape and a re-import of an unchanged role plans UNCHANGED.

Permissions on the HCL surface itself

Each family is gated on the action that guards it everywhere else in the product, not on a single HCL permission:

Family Read Write
resource action.admin.ci.view action.admin.ci.modify
role action.admin.role action.admin.role
user action.admin.users action.admin.users
group action.admin.user_groups action.admin.user_groups

This is why resource "user" "bob" is refused rather than redirected: the family is what the permission check reads.