8.8 KiB
Spec: Community / Enterprise Dual-Build Model
Context
Clavitor ships two editions from a single codebase:
- Community Edition (ELv2, open source): single vault, full encryption, CLI, browser extension, manual scope assignment
- Enterprise Edition (closed source, hosted): everything in Community + management plane APIs, auto-scope, SCIM webhook receiver, cross-vault audit feed, telemetry hooks
The split is compile-time via Go build tags. No runtime feature flags. No license key checks. Enterprise code simply doesn't exist in the community binary.
Architecture
Build tags
All enterprise-only code lives behind //go:build enterprise.
Community build: go build -o clavitor .
Enterprise build: go build -tags enterprise -o clavitor .
File structure
api/
handlers.go # shared: GET /entry, GET /totp, GET /entries, GET /search
handlers_admin.go # shared: POST/PUT/DELETE /agent, /entry (WebAuthn-gated)
handlers_enterprise.go # enterprise only (build tag): management plane APIs
routes.go # shared routes
routes_enterprise.go # enterprise only (build tag): registers enterprise routes
scopes/
scopes.go # shared: scope matching, entry access checks
autoscope.go # enterprise only (build tag): LLM-based scope classification
scim/
scim.go # enterprise only (build tag): SCIM webhook receiver
audit/
audit.go # shared: per-vault audit logging
audit_feed.go # enterprise only (build tag): cross-vault audit export feed
telemetry/
telemetry.go # enterprise only (build tag): node telemetry reporting
Enterprise route registration
Use an init pattern so enterprise routes self-register when the build tag is present:
// routes_enterprise.go
//go:build enterprise
package api
func init() {
enterpriseRoutes = append(enterpriseRoutes,
Route{"POST", "/api/mgmt/agents/bulk", handleBulkAgentCreate},
Route{"DELETE", "/api/mgmt/agents/bulk", handleBulkAgentRevoke},
Route{"GET", "/api/mgmt/audit", handleCrossVaultAudit},
Route{"POST", "/api/scim/v2/Users", handleSCIMUserCreate},
Route{"DELETE", "/api/scim/v2/Users/{id}", handleSCIMUserDelete},
Route{"POST", "/api/autoscope", handleAutoScope},
)
}
// routes.go (shared)
package api
var enterpriseRoutes []Route // empty in community build
func RegisterRoutes(mux *http.ServeMux) {
// shared routes
mux.HandleFunc("GET /api/entries", handleListEntries)
mux.HandleFunc("GET /api/entries/{id}", handleGetEntry)
mux.HandleFunc("GET /api/ext/totp/{id}", handleGetTOTP)
// ... all shared routes
// enterprise routes (empty slice in community build)
for _, r := range enterpriseRoutes {
mux.HandleFunc(r.Method+" "+r.Path, r.Handler)
}
}
Scope implementation (shared)
Implement the scope model in the shared codebase. This is needed by both editions.
Schema (add to existing DB):
-- agents table
CREATE TABLE IF NOT EXISTS agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
scopes TEXT NOT NULL DEFAULT '',
all_access INTEGER NOT NULL DEFAULT 0,
admin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
-- add scopes column to entries table
ALTER TABLE entries ADD COLUMN scopes TEXT NOT NULL DEFAULT '';
Agent ID = scope. Auto-increment ordinal, displayed as 4-char zero-padded hex (printf("%04x", id)).
Token format: cvt_ prefix + 32 bytes random (base62). Stored as sha256 hash. The cvt_ prefix allows secret scanning tools (GitHub, GitLab) to detect leaked tokens.
Matching logic:
- Entry scopes empty (
"") = owner only (all_accessrequired) - Otherwise: set intersection of agent.scopes and entry.scopes
all_access=1bypasses scope check
Flags:
| all_access | admin | Role |
|---|---|---|
| 0 | 0 | Scoped read-only (agents, family members, MSP techs) |
| 1 | 0 | Read everything, change nothing (MSP tech full access) |
| 0 | 1 | Scoped read + admin ops with WebAuthn (unusual but valid) |
| 1 | 1 | Vault owner |
Admin operations (shared, WebAuthn-gated)
All admin endpoints require: valid bearer token + admin=1 + valid WebAuthn assertion.
POST /api/agents create agent/token
GET /api/agents list agents (admin read, no WebAuthn needed)
PUT /api/agents/{id} modify agent
DELETE /api/agents/{id} revoke agent
POST /api/entries create entry
PUT /api/entries/{id} modify entry (including scopes)
PUT /api/entries/{id}/scopes modify scopes only
DELETE /api/entries/{id} delete entry
POST /api/webauthn/challenge get WebAuthn challenge (60s TTL)
WebAuthn tables:
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY,
public_key BLOB NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS webauthn_challenges (
challenge_id TEXT PRIMARY KEY,
challenge TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
WebAuthn flow
1. Client: POST /api/webauthn/challenge → vault returns random challenge (60s TTL)
2. User taps hardware key (fingerprint, face, YubiKey)
3. Browser signs challenge with hardware token's private key
4. Client sends: bearer token + X-WebAuthn-Assertion header + X-WebAuthn-Challenge header + admin request body
5. Vault verifies:
a. Bearer token valid? (agent row exists)
b. admin = 1?
c. WebAuthn signature valid against stored public key?
d. Challenge matches and not expired?
→ All yes? Execute admin operation.
→ Any no? 403.
6. Challenge deleted (single use). Expired challenges cleaned up periodically.
Auto-scope (enterprise only)
//go:build enterprise
// POST /api/autoscope
// Takes entry fields, returns suggested scope assignments
// Uses LLM to classify fields as Credential vs Identity
// and suggest which agent scopes should have access
Makefile
build:
go build -o clavitor .
build-enterprise:
go build -tags enterprise -o clavitor .
build-prod:
GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -tags enterprise -o clavitor-linux-amd64 .
Constraints
- The community binary must work standalone with zero enterprise code compiled in
- No runtime checks for "is this enterprise?" — the code paths don't exist
- No feature flags, no license keys, no env vars
- Enterprise files must all have
//go:build enterpriseas the first line - The agents table and scopes column are shared (both editions need scoping)
- WebAuthn is shared (both editions need hardware-gated admin)
- Token format
cvt_prefix is shared - Cannot delete the last admin agent (prevent lockout)
- Cannot remove admin flag from self if no other admin exists
Tier enforcement
The vault itself does not enforce tier limits. The hosted management layer (outside the vault) enforces:
- Token count:
SELECT COUNT(*) FROM agentschecked beforePOST /api/agents - Device count:
SELECT COUNT(*) FROM webauthn_credentialschecked before WebAuthn registration - Vault count: checked at the account level, not within the vault
Self-hosted (Community Edition) vaults have no limits on tokens or devices within a single vault.
Migration
For existing vaults (no scopes yet)
- Add
scopescolumn to entries:ALTER TABLE entries ADD COLUMN scopes TEXT NOT NULL DEFAULT ''; - Create agents table.
- Existing auth tokens become agent rows: create agent row for each existing token with
all_access=1, admin=1(vault owner). - All existing entries have
scopes = ""(owner-only) by default — secure default, nothing changes until user explicitly creates agents and assigns scopes.
Vault owner bootstrap
On first vault setup or migration:
- User registers WebAuthn credential (hardware key enrollment).
- System creates first agent row:
id=1, name="Owner", all_access=1, admin=1. - User receives their bearer token (shown once).
- All entries default to
scopes=""(owner only). - User creates additional agents and assigns scopes via admin operations.
Deliverables
- Implement the agents table, token auth, and scope matching in shared code
- Implement WebAuthn challenge/verify flow for admin operations
- Implement all shared API endpoints (read + admin)
- Create enterprise-only stubs (handlers_enterprise.go, routes_enterprise.go, autoscope.go, scim.go, audit_feed.go) with build tags and basic structure
- Update Makefile with both build targets
- Migration path for existing vaults (add scopes column, create initial owner agent)
Reference
- Full scope spec: /home/johan/dev/clavitor/clavis/clavis-vault/SPEC-scopes.md
- Pricing architecture: /home/johan/dev/clavitor/marketing/pricing-architecture.md