Skip to main content

Data stores

Three SQL Server databases, one per service that has state. No service reads another's tables.

ContextConnection stringMigrations assembly
AccountContextAccountsContextTagShape.Api.Account.Migrations
LibrarySetContextLibrarySetsContextTagShape.Api.LibrarySet.Migrations
AuditContextAuditContextTagShape.Api.Audit.Migrations

The identity API has no database. It reads what it needs from the account API and signs.

Accounts

Entity
OrganizationName, active flag
OrganizationIdentityProviderProvider, client ID, client secret, tenant ID
OrganizationBrandingLogo, content type, custom CSS
OrganizationAccountPatternEmail patterns permitting automatic account creation
UserName, email, organization
UserIdentityProvider and the provider's identifier for the user
UserProfileImageNormalised image and its hash
UserApiKeyKey hash, hint, description, created and expiry dates
GroupName, description
GroupPermissionOne permission on a group
UserGroupMembership

A user's permissions are the union of the permissions of their groups; there is no user-level permission table.

An API key row holds a SHA of the key, never the key. Uniqueness is enforced across every key in the system, not per organization — a key is presented on its own and resolved by hash, so two accounts sharing one would leave that lookup with two answers and no way to choose.

Library sets

Entity
TagGroupName, description, version, authorship
TagName, tag group
VariableSetName, description, version, authorship
VariableName, variable set
VariableValueValue, sensitive, hint, hash, and its tags

IDs are int and assigned per organization rather than globally, which is why the code takes the next ID explicitly rather than relying on an identity column.

Every record carries OrganizationId, and every query filters on it.

Cascades

  • A VariableSet cascades to its Variables and their VariableValues.
  • A TagGroup cascades to its Tags.
  • Value-to-tag join rows cascade from either side.

This is what lets a replacing import empty a library with two delete statements rather than loading every secret the organization holds only to count it and throw it away.

Audit

One LogEntry table.

Column
IdPer-organization, and shared across the versions of one entity's history
OrganizationIdGuid.Empty for global entries
LoggedAt, LoggedBy
VersionWhich version of the entity this entry describes
EntryType, Actione.g. Variable, Update
EntityName, EntityId
BeforeState, AfterStateJSON strings

Entries are only ever added. DELETE /{id} removes every version of one entry and is the only removal path.

Migrations

EF Core, with each service's migrations in their own assembly.

# Check whether the model has drifted from the migrations
dotnet ef migrations --project .\src\TagShape.Api.Audit.Migrations\ has-pending-model-changes

# Add one
dotnet ef migrations add <Name> `
--project .\src\TagShape.Api.Audit.Migrations\ `
--startup-project .\src\TagShape.Api.Audit.Web\

In Development, each service runs Database.MigrateAsync() at startup, so a local run brings its own schema up to date. Outside development, migrations are applied out of band — efbundle.exe at the repository root is a bundled migration runner for that.

Each DbContext is registered with a scoped context and a singleton options instance. The account and library set contexts enable sensitive data logging, which is worth being aware of when reading logs from an environment holding real secrets.