Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7eceed0
docs(config): design for bootstrap-only file/env config with DB-only …
ericfitz Aug 22, 2026
2f57ca1
docs(config): implementation plan for Phase A, the authoritative sett…
ericfitz Aug 22, 2026
51cab5c
feat(config): add SettingDef, the single authoritative setting declar…
ericfitz Aug 22, 2026
90fd606
feat(config): validate SettingDef category legality, including the tr…
ericfitz Aug 22, 2026
a7b9afd
feat(config): wire the full SettingDef registry and gate it with a bi…
ericfitz Aug 22, 2026
0ef42e8
fix(config): restore truthful YAMLPath, add Seeded validation rules, …
ericfitz Aug 22, 2026
62e82fc
fix(config): classify rate_limit.* and project DefaultSystemSettings …
ericfitz Aug 22, 2026
55f7326
fix(config): actually resolve rate_limit.* visibility for #809
ericfitz Aug 22, 2026
f08f46f
test(config): assert every reachable setting key has a classification
ericfitz Aug 22, 2026
382a6fd
test(config): re-key coverage test on YAMLPath, drop allowlist reuse
ericfitz Aug 22, 2026
71b4a22
refactor(config): project GetMigratableSettings from the registry
ericfitz Aug 22, 2026
a837465
fix(config): skip bootstrap-by-construction keys and keep the branch …
ericfitz Aug 22, 2026
e6cee63
fix(config): coerce nil-slice JSON settings and document server.trust…
ericfitz Aug 22, 2026
472bc24
fix(config): make the database-only guardrail test actually catch leaks
ericfitz Aug 22, 2026
fba3a3c
test(config): ratchet the list of operational settings still delivere…
ericfitz Aug 22, 2026
ce87f7b
chore(config): refresh SEM markers for the registry consolidation
ericfitz Aug 23, 2026
58a7f5e
fix(config): forbid Seeded on a VisibilityInternal setting (#809 class)
ericfitz Aug 23, 2026
fab5eb7
chore(config): refresh SEM marker for validateGeneralRules
ericfitz Aug 23, 2026
9d8cfb2
docs(config): reconcile the phase-A plan and coverage test with what …
ericfitz Aug 23, 2026
2572754
chore(version): bump to 1.9.0
github-actions[bot] Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .version
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"major": 1,
"minor": 8,
"patch": 22,
"minor": 9,
"patch": 0,
"prerelease": ""
}
2 changes: 1 addition & 1 deletion api-schema/tmi-openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"info": {
"title": "TMI (Threat Modeling Improved) API",
"description": "A RESTful API for collaborative threat modeling with full X6 graph library compatibility. This API provides schemas that align with AntV X6 cell object models for seamless integration with modern diagramming libraries. Supports OAuth 2.0 authentication with client callback integration for seamless single-page application authentication flows.\n\n## API Design v1.1.0\n\n### Authorization Model\nTMI uses hierarchical authorization: access control is defined at the ThreatModel level via the authorization field (readers, writers, owners). All child resources (Assets, Diagrams, Documents, Notes, Repositories, Threats) inherit permissions from their parent ThreatModel. This simplifies permission management and ensures consistent access control.\n\n### Bulk Operations\nNotes and Diagrams do not support bulk operations due to their unique creation workflows and lack of valid bulk use cases. All other resources (Threats, Assets, Documents, Repositories) support full bulk operations: POST (create), PUT (upsert), PATCH (partial update), DELETE (batch delete).\n\nAll resources support bulk metadata operations regardless of resource-level bulk support.\n\n### List Response Strategy\n- ThreatModels return summary information (TMListItem) because they contain many child objects that can be large.\n- Diagrams return summary information (DiagramListItem) because diagram data (cells, images) can be large.\n- Notes return summary information (NoteListItem) because the content field can be large.\n- Threats, Assets, Documents, Repositories return full schemas as they are relatively small and static.\n\n### PATCH Support\nAll resources support PATCH for partial updates using JSON Patch (RFC 6902). This is particularly useful for:\n- Assets: Array field updates (affected_assets, trust_boundaries) ensuring no duplicates\n- Notes: Updating name/description without changing content field\n- All resources: Efficient updates without full object replacement\n",
"version": "1.8.22",
"version": "1.9.0",
"contact": {
"name": "TMI Development Team",
"url": "https://github.com/ericfitz/tmi",
Expand Down
2,166 changes: 1,083 additions & 1,083 deletions api/api.go

Large diffs are not rendered by default.

94 changes: 38 additions & 56 deletions api/models/system_setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package models

import (
"time"

"github.com/ericfitz/tmi/internal/config"
)

// SystemSetting represents a system-wide configuration setting stored in the database.
Expand Down Expand Up @@ -102,64 +104,44 @@ func (s *SystemSetting) IsExplicit() bool {
// DefaultSystemSettings returns the default system settings that should be seeded
// when the database is initialized. These provide sensible defaults that can be
// overridden by administrators.
// SEM@8f7b5125fd7a1b5bb10210ba480278708de918b0: build the seed list of default system settings for database initialization (pure)
//
// This is a projection of the internal/config registry
// (config.SeedableOperationalDefs), not a hand-kept parallel list — a
// parallel list is what left rate_limit.requests_per_minute and
// rate_limit.requests_per_hour seeded here with no classification entry
// anywhere else, so GET/DELETE /admin/settings/{key} 404'd on keys the LIST
// endpoint showed (#809).
// SEM@62e82fc4e96a1c18f4e1ac1d698de4fefb974b42: build the seed list of default system settings for database initialization (pure)
func DefaultSystemSettings() []SystemSetting {
desc := func(s string) NullableDBText { return NullableDBText{String: s, Valid: true} }

return []SystemSetting{
{
SettingKey: "rate_limit.requests_per_minute",
Value: "100",
SettingType: SystemSettingTypeInt,
Description: desc("Maximum API requests per minute per user"),
},
{
SettingKey: "rate_limit.requests_per_hour",
Value: "1000",
SettingType: SystemSettingTypeInt,
Description: desc("Maximum API requests per hour per user"),
},
{
SettingKey: "session.timeout_minutes",
Value: "60",
SettingType: SystemSettingTypeInt,
Description: desc("JWT token expiration in minutes"),
},
{
SettingKey: "websocket.max_participants",
Value: "10",
SettingType: SystemSettingTypeInt,
Description: desc("Maximum participants per collaboration session"),
},
{
SettingKey: "features.saml_enabled",
Value: "false",
SettingType: SystemSettingTypeBool,
Description: desc("Enable SAML authentication"),
},
{
SettingKey: "features.webhooks_enabled",
Value: "true",
SettingType: SystemSettingTypeBool,
Description: desc("Enable webhook subscriptions"),
},
{
SettingKey: "features.websocket_enabled",
Value: "true",
SettingType: SystemSettingTypeBool,
Description: desc("Enable WebSocket collaboration"),
},
{
SettingKey: "ui.default_theme",
Value: "auto",
SettingType: SystemSettingTypeString,
Description: desc("Default UI theme (auto, light, dark)"),
},
{
SettingKey: "upload.max_file_size_mb",
Value: "10",
SettingType: SystemSettingTypeInt,
Description: desc("Maximum file upload size in megabytes"),
},
defs := config.SeedableOperationalDefs()
out := make([]SystemSetting, 0, len(defs))
for _, d := range defs {
out = append(out, SystemSetting{
SettingKey: DBVarchar(d.Key),
Value: DBText(d.Default),
SettingType: DBVarchar(settingTypeFor(d.Type)),
Description: desc(d.Description),
})
}
return out
}

// settingTypeFor maps a config registry type name to the stored
// system_settings setting_type value.
// SEM@62e82fc4e96a1c18f4e1ac1d698de4fefb974b42: convert a config registry type name to a stored setting_type value (pure)
func settingTypeFor(t string) string {
switch t {
case "bool":
return SystemSettingTypeBool
case "int":
return SystemSettingTypeInt
case "float":
return SystemSettingTypeFloat
case "json":
return SystemSettingTypeJSON
default:
return SystemSettingTypeString
}
}
39 changes: 39 additions & 0 deletions api/models/system_setting_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ package models
import (
"encoding/json"
"testing"

"github.com/ericfitz/tmi/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestSystemSetting_IsExplicit pins the #794 precedence rule and, critically,
Expand Down Expand Up @@ -92,3 +96,38 @@ func TestSystemSetting_OriginJSONRoundTrip(t *testing.T) {
t.Error("round-tripped seeded row reports IsExplicit() == true; Origin was lost in JSON marshaling")
}
}

// TestDefaultSystemSettings_MatchesRegistryProjection pins that
// DefaultSystemSettings is a projection of config.SeedableOperationalDefs,
// not a hand-kept parallel list — a parallel list is what left rate_limit.*
// seeded here with no classification entry anywhere else (#809).
func TestDefaultSystemSettings_MatchesRegistryProjection(t *testing.T) {
defs := config.SeedableOperationalDefs()
seeds := DefaultSystemSettings()

require.Equal(t, len(defs), len(seeds),
"DefaultSystemSettings must be a projection of the registry, not a parallel list")

byKey := map[string]SystemSetting{}
for _, s := range seeds {
byKey[string(s.SettingKey)] = s
}
for _, d := range defs {
s, ok := byKey[d.Key]
require.True(t, ok, "registry declares %s but DefaultSystemSettings does not seed it", d.Key)
assert.Equal(t, d.Default, string(s.Value), "seed value for %s must be the registry Default", d.Key)
assert.Equal(t, d.Description, s.Description.String, "seed description for %s", d.Key)
}
}

// TestDefaultSystemSettings_SeedsPreviouslyUnclassifiedRateLimitKeys pins
// the #809 fix: both rate_limit.* keys are still seeded after the switch to
// a registry projection.
func TestDefaultSystemSettings_SeedsPreviouslyUnclassifiedRateLimitKeys(t *testing.T) {
keys := map[string]bool{}
for _, s := range DefaultSystemSettings() {
keys[string(s.SettingKey)] = true
}
assert.True(t, keys["rate_limit.requests_per_minute"])
assert.True(t, keys["rate_limit.requests_per_hour"])
}
4 changes: 2 additions & 2 deletions api/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ var (
// Major version number
VersionMajor = "1"
// Minor version number
VersionMinor = "8"
VersionMinor = "9"
// Patch version number
VersionPatch = "22"
VersionPatch = "0"
// VersionPreRelease is the pre-release label (e.g., "rc.0", "beta.1"), empty for stable releases
VersionPreRelease = ""
// GitCommit is the git commit hash from build
Expand Down
3 changes: 2 additions & 1 deletion config-example.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# TMI Example Configuration (bootstrap keys only)
# GENERATED by `make generate-config-example` on 2026-06-11T20:57:37Z — do not edit by hand.
# GENERATED by `make generate-config-example` on 2026-08-22T23:34:47Z — do not edit by hand.
# Operational and shared configuration lives in the database settings service.
# Secret values are shown as vault:// reference placeholders.

Expand Down Expand Up @@ -48,4 +48,5 @@ server:
read_timeout: 5s
tls_enabled: false
tls_subject_name: localhost
trusted_proxies: []
write_timeout: 10s
Loading
Loading