NOTICE: All information contained herein is, and remains
the property of TechnoCore.
The intellectual and technical concepts contained
herein are proprietary to TechnoCore and dissemination of this information or reproduction of this material
is strictly forbidden unless prior written permission is obtained
from TechnoCore.
Report levels:
1,2 : Open to the world, 1 for website and 2 for dashboards and screens.
The distinction is purely for administration.
5 - 9 : Reports and forms open to any user eg. summaries, user queries
and policies
10 - 19 : Active system agents.
20+ : System administration
Reports levels combine with user groups.
Public exposure is fail-closed. A public Level (1/2) is necessary but NOT
sufficient — an anonymous caller only reaches a report/form that ALSO carries
the PUBLIC group (AclConstants.PUBLIC_GROUP). This prevents a low-Level
item from being accidentally world-readable. Tag every genuinely public surface
(landing, login) with group PUBLIC.
Access control has two independent axes that form a grid:
CREDIT, DEBICHECK, SUPPORT). Stored on sys_user.UserGroups andUserGroup / Groups).PUBLIC=1 … SUPERUSER=20). Stored on sys_user.Level and on eachLevel.The check (ObjAcl) is simply:
access = ( item.group in DOC_GROUPS or user.group == item.group )
and user.level >= item.level
A user may belong to more than one group — UserGroups is a comma list
(e.g. AGENT,SUPPORT). Membership is the union, so the user can reach any
report/form scoped to any of their groups (subject to level).
Groups can also denote a branch / location / region, not just a role —
they are simply named principal sets. e.g. BRANCH_CPT, BRANCH_JHB,
REGION_WC. Combine with a functional group via the comma list
(CREDIT,BRANCH_CPT); a report/form tagged with a branch group is then scoped
to that branch. Because the group dimension is OR (a user reaches an item
matching any of its groups), use the company axis — which the ACL
intersects (group ∩ company) — when you need true function-and-branch
scoping (e.g. "credit reports, Cape Town only").
Documentation-only groups (AclConstants.DOC_GROUPS = PUBLIC, ADMIN,
ROOT, plus the legacy wildcard ALL): membership is not enforced — the
group on the report/form is documentation/reference and the Level alone
gates. They are full-width rows across the grid: PUBLIC(1) spans the bottom
(everyone), ADMIN(18)/ROOT(20) span the top (any department). Pair such a
group with an aligned Level (e.g. group=ADMIN only means admins if the item's
Level is the ADMIN level, 18).
The canonical base groups + 1–20 levels are code members
(AclConstants.GROUPS / AclConstants.LEVELS) — the runtime authority. The
deploy-time installer (factory.deploy/ObjSeed) writes them to def_usergroup
/ def_level, taking the human descriptions from ObjSeed.yaml. User
carries no seeding — provisioning is deploy-time only
(ObjSeed.py levels / groups, or all).
Rows = enforced department groups, columns = privilege tiers (levels); each
cell is a real (group, level) role:
| Group \ Level | Operator (5) | Senior (9) | Supervisor (12) | Manager (15) |
|---|---|---|---|---|
| AGENT | Onboarding agent | Senior agent | Sales supervisor | Sales manager |
| COLLECTIONS | Collector | Senior collector | Collections supervisor | Collections manager |
| DEBICHECK | Mandate operator | Mandate specialist | DebiCheck supervisor | — |
| SUPPORT | Helpdesk agent | Support specialist | Support lead | — |
| FLEET | Fleet operator | Fleet controller | Fleet supervisor | — |
| FINANCE | Clerk | Reconciliation analyst | Finance supervisor | Finance manager |
| CREDIT | Capture clerk | Underwriter | Credit supervisor | Credit manager |
| RISK | — | Risk analyst | Risk lead | Head of risk |
| COMPLIANCE | — | Compliance officer | Compliance lead | — |
The grid is sparse — most of the 13×20 cross-product is meaningless; only
the named cells are real roles. A report at (DEBICHECK, 12) is reachable only
by DebiCheck staff at supervisor level+; a person at (RISK, 9) reaches
RISK-scoped items up to level 9.
Full model, rationale and retro-patch notes:
resource.notes/security/acl_group_level_model.md.
Verifiers and CSPs SHALL require passwords to be a minimum of eight characters in length and SHOULD require passwords to be a minimum of 15 characters in length.
Verifiers and CSPs SHOULD permit a maximum password length of at least 64 characters.
Verifiers and CSPs SHOULD accept all printing ASCII [RFC20] characters and the space character in passwords.
Verifiers and CSPs SHOULD accept Unicode [ISO/ISC 10646] characters in passwords. Each Unicode code point SHALL be counted as a single character when evaluating password length.
Verifiers and CSPs SHALL NOT impose other composition rules (e.g., requiring mixtures of different character types) for passwords.
Verifiers and CSPs SHALL NOT require users to change passwords periodically. However, verifiers SHALL force a change if there is evidence of compromise of the authenticator.
Verifiers and CSPs SHALL NOT permit the subscriber to store a hint that is accessible to an unauthenticated claimant.
Verifiers and CSPs SHALL NOT prompt subscribers to use knowledge-based authentication (KBA) (e.g., “What was the name of your first pet?”) or security questions when choosing passwords.
Verifiers SHALL verify the entire submitted password (i.e., not truncate it).
X Require all passwords to be at least 8 characters in length. This rule is needs to be relaxed for federated login.
X Require complex passwords, consisting of both numeric and alphabetic characters.
Require that new passwords cannot be the same as the 4 previously used passwords.
Lock out accounts after not more than 6 invalid logon attempts.
Require that once a user account is locked out it remains locked for 30 minutes or until the System Administrator resets the account.
X Require system/session idle time out of 15 minutes.
Require passwords to be reset at least every 90 days.
Encrypt all passwords during transmission and storage on all system components (e.g. in scripts and databases, connection strings, inside compiled code, etc).
Immediately revoke access for any terminated users.
X Disable user accounts that have been inactive for 90 days.
First-time passwords must be set to a unique value for each user.
First-time passwords must be changed after the first use.
Reset passwords must be set to a unique value for each user.
Reset passwords must be changed after the first use.
Account creation requests must specify access either explicitly or via a role that has been mapped to the required access.
Access must be immediately revoked for terminated or transferred users or for any user
whose access is no longer required. Ensure that access privileges are revoked as soon
as possible.
User IDs shall be disabled after ninety (90) days of inactivity. These requirements may not
apply to certain specialised accounts (e.g., admin, root, etc.).
Exceptions to this process will require a waiver to be raised by the requestor, reviewed by
the TFG ISD and approved by the TFG's CIO.
Passwords set by System Administrators must be changed by the user immediately upon the
users' next login. System Administrators must set initial passwords that are unique
and compliant with the password rules.
//: <> (CYTHON START)
cythonize -3 -a -i ObjUser.py
Compiling /home/axion/projects/axion/factory.web/ObjUser.py because it changed..[1/1] Cythonizing /home/axion/projects/axion/factory.web/ObjUser.py
Updated : 2025-10-02
Per standards/naming_snake_case_migration.md, this module's own
methods were renamed and given full type annotations:
| Was | Now |
|---|---|
ReviewPassword |
review_password |
ReviewLogin |
review_login |
ReviewCompany |
review_company |
ReviewGroup |
review_group |
ReviewAvatar |
review_avatar |
ReviewUser |
review_user |
UpdateFromFederation |
update_from_federation |
Validate |
validate |
GetFullname |
get_fullname |
QueryPassword |
query_password |
Listed |
listed (UserSet) |
Activate |
activate |
PascalCase params went with them (Password → password: str,
Level → level: int).
Create() was deleted, not renamed. It was a dead shim over
create(). It could not simply be left to fall through either:
ObjData also defines Create(Table=""), so an un-migrated
user.Create("someone@example.com") would bind Table to the email
and quietly do the wrong thing instead of failing. Both callers (in
ObjTestSeed) were migrated first — and both sat inside
except BaseException, which would have swallowed the error.
Read, Update and Transform stay PascalCase here. They are the
cross-class ObjData contract — 42, 28 and 4 definitions repo-wide —
and renaming one class's copy breaks polymorphic dispatch for every
caller that holds an ObjData. That is a platform-wide migration, not
an ObjUser change. Note the SQL identifiers inside this module's
queries (User, Password, Changetime) stay PascalCase too: that
rule is orthogonal to Python naming.
update_password() — the only way to change a passwordUpdate() does not write the password. It writes
Lastinteraction and nothing else, despite the name. So this, which
looks entirely reasonable, changes nothing and reports no error:
user.Read(login, package=pkg)
user._Password = "newpass"
user.Update() # writes Lastinteraction. That is all.
Use update_password() instead:
user = ObjUser.User()
user.Read("someone@example.com", package="TECHNOCORE")
if not user.update_password("newpass"):
... # it did NOT change -- do not report success
It encrypts through self.encrypt(value, package) (so the plaintext
never reaches the column), escapes every interpolated value, sets
PasswordMustChange='N' and PasswordAge=NOW() like ObjAcl's reset
path, and verifies by reading the password back through
query_password — the same call the login form makes.
That read-back is not belt-and-braces. sql_execute swallows errors,
so "the UPDATE ran" is not evidence of anything (the same trap
documented for create() below), and the stored form is encrypted, so
comparing the column against the plaintext would fail even on success.
The only question worth asking is whether the login path now accepts
the new password, so that is the question it asks.
Refuses, returning False rather than writing:
Read() — note a fresh User() has no_User member at all, so the guard uses getattr; reading itAttributeError instead of refusing cleanly.query_password() re-encrypts on a successful login and then calls
self.Update() to persist it — which, per the above, persists
nothing. So a password stored as plaintext stays plaintext no matter
how many times the user logs in. Recorded 2026-08-24; the fix belongs
with the wider encryption-at-rest work, not in this method. <>
create() inserted nothing at allFixed 2026-08-18. Two independent faults, either of which alone would
have been enough:
user_uuid, which is not a column on sys_user.Uuid, and it is the primary key.Uuid assignment.sql_execute swallows the error, so create() returned normally having
created nothing. ObjTestSeed seeded no users and ObjReportChat
created no chat participants, silently, on every call. Verified against
the live table: the INSERT raises nothing and the row is simply absent
afterwards.
Two more spellings were wrong in the same statement — Registerdate for
RegisterDate and UserGroups for Usergroups. MySQL may fold case on
this platform, but column names are treated as case sensitive here,
so both were drift regardless.
database.queries.create_user already existed and the code ignored it.
It was not a usable substitute:
| Missing | Consequence |
|---|---|
Active |
defaults to ''; every reader filters Active = 'Y' (ObjNotify.get_all_users_in_package, ObjHook, ObjAcl), so the user was invisible to all of them |
Usergroups |
it wrote UserGroup only — a real column, but NULL even on the live admin row. ACL membership is read from Usergroups (ObjAcl._Usergroups), so the user had no groups |
UuidDate |
set alongside the Uuid everywhere else |
The reference for a working user INSERT is ObjAcl's
create_joincode_user, which sets Active='Y', both usergroup columns
to the same value, UuidDate, Level and the company columns.
create_user now mirrors it.
The old update_new_user ran unconditionally after the INSERT. Had
it been syntactically valid it would have blanked an existing user's
password and rewritten their Uuid — the primary key, referenced by
sys_user_history, PersonGuid and every session row.
ObjReportChat calls create() once per conversation sender, with names
it did not choose. So a working version of that statement was an
account-takeover path, not a convenience. The query is deleted and
create() now returns False without writing when the user exists.
create() also reads the row back rather than trusting
sql_execute, and returns a bool. That is the whole lesson of this bug:
sql_execute raised nothing for a statement naming a non-existent
column, so the only reliable signal is whether the row is there. A count
it cannot parse is treated as "do not write", never as "absent" —
collapsing those turns the guard back into the unconditional write it
replaced.
create() writes the value it receives. The platform's convention is
that a ~ prefix marks a hashed password (~ + sha224, as
create_on_federation does) and anything else is plaintext subject to
the min-length policy. A caller that wants a hash must pass the
~-prefixed form. This is unchanged behaviour, called out because it is
not obvious from the signature.
update_login set Active = '1'. Nothing on the platform reads
'1', and ObjNotify, ObjHook and ObjAcl all filter
Active = 'Y' — so a user's first successful login dropped them out of
every one of those queries. The single real row on a live instance is
'Y'. Fixed in the same change.
ObjUser.yaml now carries only the two sections every other module
uses: database: (connection + queries) and constants:.
It previously held three sections that appear in no other companion
YAML in the repository — measured: 222 files use database.schema,
and ObjUser was the only file using database.schemas, the only one
with configuration:, and the only one with comments:. For the
foundation class of user management, being the sole outlier was
backwards.
What happened to each:
| was | now | why |
|---|---|---|
database.schemas: |
removed, documented below | a pointer map read by no code |
configuration: |
moved into constants: |
written for a get_yaml_config() accessor that was never built |
comments: |
removed | duplicated this file, and documented an API that does not exist |
The schemas: block was a documentation pointer, not a declaration —
nothing read it. The tables are defined by the package-sync schema
system, whose files use an append: / create: {cols, keys} shape
rather than CREATE strings:
| table | defined in |
|---|---|
sys_user |
local.processing/schema/package.sync/tables/sys_user.yaml |
sys_user_history |
local.processing/schema/package.sync/tables/sys_user_history.yaml |
def_usergroup |
local.processing/schema/package.sync/tables/def_usergroup.yaml |
def_company |
factory.core/ObjData.yaml — a real database.schema CREATE |
ObjUser declares no tables of its own today. Adding a
database.schema: block is a deliberate, separate change: it would
start creating and altering tables that the sync system currently owns,
which is a behavioural change and not part of a reshape.
constants: — and what is not yet readauth_key_secret is live, read by generate_auth() and check_auth()
via get_module_constant(). It is still git-tracked: rotate it on
Keycloak/auth — see resource.notes/security_rotation_backlog.md,
where it is the top priority.
The other five — password_policy, otp_defaults, user_defaults,
avatar_defaults, standard_groups — are read by nothing. They
came from the old configuration: section, which was inert because its
intended accessor was never written. They are kept so the intent is not
lost, and they are reachable through get_module_constant() now, but
they should be adopted or deleted rather than left looking live.
Two of them duplicate values hardcoded in ObjUser.py:
avatar_defaults.service and .size are also literals at
ObjUser.py:303-307. A third, configuration.auth.default_key_secret,
was a drifted duplicate of auth_key_secret — the same conceptual
signing key with a different value, where only auth_key_secret was
ever read. It was deleted rather than moved. <>