ores.iam.account
Table of Contents
An account that can authenticate against the system: one row per user,
service, algorithm or LLM identity, carrying the password material, the
TOTP secret, the email address and the optional profile and reporting
links. The table is bi-temporal and audited (see
projects/ores.sql/create/iam/iam_accounts_create.sql): it carries
version, the four audit columns and the valid_from=/=valid_to pair
with the GIST exclusion, so the model takes the ordinary audited shape
and needs no shape flag.
The table is a composite parent: ores_iam_accounts_touch_version_fn
lets a child entity (account contact information, party association)
bump this account's own version when the child is written. The model
declares :generate_touch_function: true, which renders that function
under its existing name rather than leaving it hand-written.
The model describes the table and nothing else. Two columns need care:
service_password_hashis a real column with no domain member: it is reached only bycheck_service_credentialsand never travels on the wire, so it is declared:sql_only: trueand the generated domain struct omits it while the entity struct and the mapper keep it.image_idandreports_to_account_idare nullable UUID soft references. The hand-written domain struct represented both as a plainboost::uuids::uuidwith a nil sentinel, on the claim that a secondstd::optional<boost::uuids::uuid>member corrupts reflect-cpp aggregate serialisation for multi-element vectors. Re-verified under the generated estate: all three nullable UUIDs are modelled asstd::optional<boost::uuids::uuid>, and the api suite's multi-element JSON and table tests plus the core repository's five-account round trip pass, so the workaround is not needed here.
The generated read surface is live, and it does not collide with the
hand-written one. The hand-written
account_operations_protocol.hpp owns the writes,
iam.v1.accounts.{save,delete,update,lock,unlock,change-password,reset-password,select-party,set-default-party,switch-party,update-email,publish-from-dq},
and the generated account_protocol.hpp owns the reads,
iam.v1.accounts.list and iam.v1.accounts.get, with the version reads
alongside them. Both registrars are composed in
ores.iam/core/src/messaging/registrar.cpp. This entity sets
:read_only: true, so the generated half carries no write verb and the split
falls out of the flag rather than out of a suppression.
Two behavioural facets are switched off, each with a reason:
- The entity's CRUD handler and sub-registrar, because the hand-written
account_operations_handleralready owns the write verbs. - The generated CRUD service, because the hand-written
account_operations_serviceis the authentication surface (login, lock, unlock, password change and reset, party selection, service-credential check) and the generated service'sget_account_history(id)collides in name and signature with the hand-writtenget_account_history(username)while meaning a different read.
1. Flags
2. Columns
2.1. id
Unique identifier for the account.
2.2. account_type
Account type classification. Determines account capabilities: user
accounts can login with password, while service, algorithm and
llm accounts authenticate via sessions.
std::string("user")
2.3. username
Unique username for login purposes. It is the natural key, so the
generated table adds the partial unique index on (tenant_id, username)
the hand-written table already had.
std::string(faker::internet::username(std::string(faker::person::firstName()), std::string(faker::person::lastName())))
2.4. full_name
The account holder's full (real) name. Not every account represents a person (service, algorithm and llm accounts leave this empty), and this is the only place a human account's real name is recorded.
std::string(faker::person::firstName()) + " " + std::string(faker::person::lastName())
2.5. password_hash
Hashed password for secure authentication.
ctx.alphanumeric(64)
2.6. password_salt
Salt used in password hashing for additional security.
ctx.alphanumeric(32)
2.7. service_password_hash
SHA-256 hash of a service account's password, stored as hex. It is
reached only by check_service_credentials and never travels on the
wire, so it is a SQL-only column: the generated domain struct omits it
and the entity struct and mapper keep it.
2.8. totp_secret
Time-based One-Time Password secret for two-factor authentication.
ctx.alphanumeric(32)
2.9. email
Email address associated with the account. It is unique within a
tenant, so the generated table adds the partial unique index on
(tenant_id, email) the hand-written table already had.
std::string(faker::internet::email(std::string(faker::person::firstName()), std::string(faker::person::lastName())))
2.10. default_party_id
Party to log into automatically when quick-login is enabled. Soft reference to a party owned by ores.refdata; unset means the account must always go through the party picker.
2.11. image_id
Profile picture for this account. Soft reference to an image owned by ores.assets; unset means the account has no profile picture.
2.12. job_title
Job title / functional role of the person holding this account (for example "Head of Desk", "Senior Trader"). Distinct from the RBAC role assignments, which grant coarse permission sets rather than describe what the person actually does.
faker::person::jobTitle()
2.13. reports_to_account_id
The account this person reports to, capturing the functional reporting line. Soft self-reference to another row in this same table; unset means no reporting line is recorded.
3. Artefact columns
The import carries business_unit_code, role, reports_to_username and photo_key, all four written by the Acme populate and all four absent from the store in that form: business_unit_code is informational only today because ores_iam_accounts_tbl has no business-unit FK, and reports_to_username is resolved to reports_to_account_id at publish. The password salt, the service password hash and the TOTP secret are deliberately absent: the publish supplies empty values and staging is not where those belong.
3.1. id
3.2. version
3.3. username
3.4. full_name
3.5. email
3.6. password_hash
3.7. account_type
3.8. business_unit_code
3.9. role
3.10. job_title
3.11. reports_to_username
3.12. photo_key
4. SQL
4.1. Flags
5. Foreign keys
5.1. image_id
Profile picture for this account, validated against the images table when set.
5.2. reports_to_account_id
Reporting line, validated against this same table when set.
6. Insert trigger
6.1. Validations
| column | validation_function |
|---|---|
| account_type | ores_iam_validate_account_type_fn |
7. C++
7.1. Flags
7.2. Repository
7.3. Domain includes
#include <boost/uuid/nil_generator.hpp> #include <boost/uuid/uuid.hpp> #include <chrono> #include <optional> #include <string>
7.4. Entity includes
#include <string> #include "sqlgen/PrimaryKey.hpp" #include "sqlgen/Timestamp.hpp"
7.5. Conventions
7.6. Table display
| column | header |
|---|---|
| id | ID (UUID) |
| username | Username |
| full_name | Full Name |
| job_title | Job Title |
| change_reason_code | Change Reason |
| modified_by | Modified By |
| recorded_at | Recorded At |
| version | Version |
7.7. Presentation
The screen's declaration. An account is addressed by its username, the
natural key, which is what every caller already holds: an operator knows the
name an account signs in with, not its surrogate id. The id stays for
foreign-key stability, which is what it is for, and no caller states it.
username is the key the module's own shell commands already take, so the
declaration states what its callers already assume rather than asking them to
translate.
7.7.1. Detail fields
| field | label | widget | type | is_key | is_required | placeholder |
|---|---|---|---|---|---|---|
| username | Username | usernameEdit | line_edit | true | true | Enter the account name |
| full_name | Full Name | fullNameEdit | line_edit | Enter the full name | ||
| emailEdit | line_edit | Enter the email address | ||||
| job_title | Job Title | jobTitleEdit | line_edit | Enter the job title | ||
| account_type | Type | accountTypeEdit | line_edit | Enter the account type |
7.7.2. Columns
| enum_name | field | header | type | width |
|---|---|---|---|---|
| Username | username | Username | string | 200 |
| FullName | full_name | Full Name | string | 240 |
| string | 260 | |||
| JobTitle | job_title | Job Title | string | 200 |
| AccountType | account_type | Type | string | 140 |
7.8. Paste blocks
7.8.1. read_all
Reads every version of every account. The generated CRUD set reads all versions of one account by primary key, which is what the history path needs; the CLI export and the repository test read the whole table, so that unfiltered read stays declared here, unscoped by tenant exactly as the hand-written repository was.
std::vector<domain::account> read_all(context ctx);
std::vector<domain::account> account_repository::read_all(context ctx) { const auto query = sqlgen::read<std::vector<account_entity>> | order_by("valid_from"_c.desc()); return execute_read_query<account_entity, domain::account>( ctx, query, [](const auto& entities) { return account_mapper::map(entities); }, lg(), "Reading all accounts."); }
7.8.2. read_latest_by_username
Reads the latest active account with the given username. The generated CRUD set reads by primary key only, and the login and password-reset paths address the row by username, so the read stays declared here. Deliberately unscoped by tenant, exactly as the hand-written repository was: the login flow resolves the account before the tenant is known.
std::vector<domain::account> read_latest_by_username(context ctx, const std::string& username);
std::vector<domain::account> account_repository::read_latest_by_username(context ctx, const std::string& username) { BOOST_LOG_SEV(lg(), debug) << "Reading latest account by username: " << username; static const auto max(make_timestamp(MAX_TIMESTAMP, lg())); const auto query = sqlgen::read<std::vector<account_entity>> | where("username"_c == username && "valid_to"_c == max.value()) | order_by("valid_from"_c.desc()); return execute_read_query<account_entity, domain::account>( ctx, query, [](const auto& entities) { return account_mapper::map(entities); }, lg(), "Reading latest account by username"); }
7.8.3. read_latest_by_email
Reads the latest active account with the given email. The password-reset and self-service paths address the row by email, so the read stays declared here rather than forcing those callers onto a full table scan.
std::vector<domain::account> read_latest_by_email(context ctx, const std::string& email);
std::vector<domain::account> account_repository::read_latest_by_email(context ctx, const std::string& email) { BOOST_LOG_SEV(lg(), debug) << "Reading latest account by email: " << email; static const auto max(make_timestamp(MAX_TIMESTAMP, lg())); const auto query = sqlgen::read<std::vector<account_entity>> | where("email"_c == email && "valid_to"_c == max.value()) | order_by("valid_from"_c.desc()); return execute_read_query<account_entity, domain::account>( ctx, query, [](const auto& entities) { return account_mapper::map(entities); }, lg(), "Reading latest account by email"); }
7.8.4. check_service_credentials
Verifies a non-human account's credentials without loading the account through the domain type: the stored hash is a SQL-only column, so the comparison runs against the entity. Returns the account UUID on success, or nullopt when the account is absent, is a user account, or the SHA-256 of the supplied password does not match.
#include <array> #include <format> #include <openssl/evp.h> #include <boost/lexical_cast.hpp> #include <boost/uuid/uuid_io.hpp>
std::optional<boost::uuids::uuid> check_service_credentials(context ctx, const std::string& username, const std::string& password);
std::optional<boost::uuids::uuid> account_repository::check_service_credentials(context ctx, const std::string& username, const std::string& password) { BOOST_LOG_SEV(lg(), debug) << "Checking service credentials for: " << username; static const auto max(make_timestamp(MAX_TIMESTAMP, lg())); const auto query = sqlgen::read<std::vector<account_entity>> | where("username"_c == username && "valid_to"_c == max.value()) | limit(1); const auto r = sqlgen::session(ctx.connection_pool()).and_then(query); ensure_success(r, lg()); if (!r || r->empty()) return std::nullopt; const auto& entity = r->front(); if (entity.account_type == "user") { BOOST_LOG_SEV(lg(), debug) << "Rejecting user account for service login: " << username; return std::nullopt; } std::array<unsigned char, EVP_MAX_MD_SIZE> digest{}; unsigned int digest_len = 0; EVP_Digest(password.data(), password.size(), digest.data(), &digest_len, EVP_sha256(), nullptr); std::string computed_hash; computed_hash.reserve(digest_len * 2); for (unsigned int i = 0; i < digest_len; ++i) computed_hash += std::format("{:02x}", digest[i]); if (!entity.service_password_hash.has_value() || computed_hash != entity.service_password_hash.value()) { BOOST_LOG_SEV(lg(), debug) << "Password mismatch for service account: " << username; return std::nullopt; } return boost::lexical_cast<boost::uuids::uuid>(entity.id.value()); }
8. See also
- ores.iam — component group overview.