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
groupslist and nograntblocks. The permissions come from the groups' owngrantblocks. - In no groups — no
groupsattribute, and onegrantblock 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 credentialsHCL204: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.