ADR-006: Consolidate User Tables
Status | Date | Author(s) |
---|---|---|
Proposed | 2025-04-16 | @nscuro |
Context¶
The current data model treats managed users, LDAP users, and OIDC users as different entities.
This leads to redundancies, both in database tables and application code.
Data Model¶
erDiagram
ldap_user {
bigint id pk
text dn
text username
text email
}
managed_user {
bigint id pk
text username
text fullname
text email
timestamptz last_password_change
bool non_expiry_password
bool force_password_change
bool suspended
}
oidc_user {
bigint id pk
text subject_identifier
text username
text email
}
permission {
bigint id pk
text name
}
team {
bigint id pk
text name
}
ldap_user }o--o{ permission: "has"
managed_user }o--o{ permission: "has"
oidc_user }o--o{ permission: "has"
ldap_user }o--o{ team: "is member of"
managed_user }o--o{ team: "is member of"
oidc_user }o--o{ team: "is member of"
team }o--o{ permission: "has"
Drawbacks¶
- The uniqueness of usernames cannot be enforced across all user tables.
- Queries to determine or modify permissions of a user are unnecessarily complex.
- Higher cognitive burden due to the many redundant relationships.
Decision¶
- Consolidate all user tables in a single table.
- Use a discriminator column to keep different types apart.
- Leverage check constraints in the database to handle invariants.
- Use inheritance strategies for mapping the new model to JDO classes.
@jhoward-lm provides a more thorough draft implementation in this Gist.
Data Model¶
erDiagram
permission {
bigint id pk
text name
}
team {
bigint id pk
text name
}
user {
bigint id pk
enum type
text username
text fullname
text email
text ldap_dn
text oidc_subject_identifier
text password
timestamptz last_password_change
bool non_expiry_password
bool force_password_change
bool suspended
}
user }o--o{ permission: "has"
user }o--o{ team: "is member of"
team }o--o{ permission: "has"
Invariants¶
Not all fields make sense for all user types:
- LDAP and OIDC users don't have a password.
- Managed users have no LDAP DN or OIDC subject identifier.
Such invariants should be prevented at the database level, using check
constraints. For example:
ADD CONSTRAINT check_user_type
CHECK ("TYPE" IN ('MANAGED', 'LDAP', 'OIDC'));
ALTER TABLE "USER"
ADD CONSTRAINT check_managed_columns
CHECK (
("TYPE" = 'MANAGED'
AND "FORCE_PASSWORD_CHANGE" IS NOT NULL
AND "LAST_PASSWORD_CHANGE" IS NOT NULL
AND "NON_EXPIRY_PASSWORD" IS NOT NULL
AND "PASSWORD" IS NOT NULL
AND "SUSPENDED" IS NOT NULL)
OR
("TYPE" != 'MANAGED'
AND "FORCE_PASSWORD_CHANGE" IS NULL
AND "FULLNAME" IS NULL
AND "LAST_PASSWORD_CHANGE" IS NULL
AND "NON_EXPIRY_PASSWORD" IS NULL
AND "PASSWORD" IS NULL
AND "SUSPENDED" IS NULL)
);
ALTER TABLE "USER"
ADD CONSTRAINT check_ldap_columns
CHECK (
("TYPE" = 'LDAP' AND "DN" IS NOT NULL)
OR ("TYPE" != 'LDAP' AND "DN" IS NULL)
);
ALTER TABLE "USER"
ADD CONSTRAINT check_oidc_columns
CHECK ("TYPE" = 'OIDC' OR "SUBJECT_IDENTIFIER" IS NULL);
JDO Mapping¶
To ease interaction with this consolidated model using JDO / DataNucleus, the mapping will
be adjusted to use the inheritance
strategy SUPERCLASS_TABLE
.
The abstract User
class will hold all fields and relationships that are common among all user types.
@PersistenceCapable(table = "USER")
@Discriminator(column = "TYPE", strategy = DiscriminatorStrategy.VALUE_MAP, value = "USER")
@Inheritance(strategy = InheritanceStrategy.NEW_TABLE)
public abstract class User implements Serializable, Principal {
@PrimaryKey
@Persistent(valueStrategy = IdGeneratorStrategy.NATIVE)
private long id;
@Persistent(table = "USERS_TEAMS")
@Join(column = "USER_ID")
@Element(column = "TEAM_ID")
private List<UserTeam> teams;
@Persistent(table = "USERS_PERMISSIONS")
@Join(column = "USER_ID")
@Element(column = "PERMISSION_ID")
private List<PermissionsImpl> permissions;
@Persistent
@Column(name = "USERNAME")
private String username;
@Persistent
@Column(name = "EMAIL")
private String email;
@PersistenceCapable
@Inheritance(strategy = InheritanceStrategy.SUPERCLASS_TABLE)
@Discriminator(value = "LDAP")
public static class Ldap extends User {
@Persistent
@Column(name = "DN")
private String dn;
}
@PersistenceCapable
@Inheritance(strategy = InheritanceStrategy.SUPERCLASS_TABLE)
@Discriminator(value = "MANAGED")
public static class Managed extends User {
@Persistent
@Column(name = "PASSWORD")
private String password;
@Persistent
@Column(name = "LAST_PASSWORD_CHANGE")
private Date lastPasswordChange;
@Persistent
@Column(name = "FULLNAME")
private String fullname;
@Persistent
@Column(name = "SUSPENDED")
private boolean suspended;
@Persistent
@Column(name = "FORCE_PASSWORD_CHANGE")
private boolean forcePasswordChange;
@Persistent
@Column(name = "NON_EXPIRY_PASSWORD")
private boolean nonExpiryPassword;
}
@PersistenceCapable
@Inheritance(strategy = InheritanceStrategy.SUPERCLASS_TABLE)
@Discriminator(value = "OIDC")
public static class Oidc extends User {
@Persistent
@Column(name = "SUBJECT_IDENTIFIER")
private String subjectIdentifier;
}
}
Consequences¶
- Existing user records will need to be migrated.
- Need to decide if we also want to consolidate REST API endpoints or keep the current endpoints and responses to avoid breaking changes. This could be a multi-stage effort as well.