Documentation
¶
Overview ¶
Package configshare is the scoped, self-service config-file editor: a per-(bot × repo × config-file × category) grant, addressed by a dynamic URL and authenticated by its own token, that lets a non-operator edit ONLY a declared allow-list of fields (the veille's feeds[] + editorial) in one file of one repo — and nothing else in iterion.
The grant is a synthetic principal (auth.KindShare): the auth layer refuses it every operator RBAC gate. Reads project the file down to the grant's visible paths (never the whole file); writes walk a strict pre-merge allow-list, merge onto the server-read file, re-validate, and land through forge.FileClient with an if-match SHA (no clone, no race with a bot's state push). Every commit-shaping field is server-derived from the pinned record.
Index ¶
- Constants
- Variables
- func ApplyPatch(full, patch map[string]any, allowedPaths []string) (map[string]any, []string, error)
- func DeriveGrant(editable, visible []string, category string, selectedFields ...string) (allowed []string, visibleOut []string, err error)
- func EnsureSchema(ctx context.Context, db *mongo.Database) error
- func HasCategoryPlaceholder(sets ...[]string) bool
- func MintToken() (plaintext, hash, last4, fingerprint string, err error)
- func ProjectConfig(full map[string]any, visiblePaths []string) map[string]any
- func RepoSlug(repoURL string) (string, error)
- func ValidateConfigPath(p string) error
- func ValidateLeaf(dotted string, value any) error
- func ValidatePaths(allowed, visible []string) error
- func ValidateRepoRef(ref string) error
- func VerifyToken(presented, storedHash string) bool
- type Delivery
- type MemoryStore
- func (m *MemoryStore) Create(_ context.Context, s *Share) error
- func (m *MemoryStore) Delete(_ context.Context, id string) error
- func (m *MemoryStore) GetByID(_ context.Context, id string) (*Share, error)
- func (m *MemoryStore) ListByTenant(_ context.Context, tenantID string) ([]*Share, error)
- func (m *MemoryStore) ListDeliveries(_ context.Context, shareID string, limit int) ([]*Delivery, error)
- func (m *MemoryStore) RecordDelivery(_ context.Context, d *Delivery) error
- func (m *MemoryStore) Touch(_ context.Context, id string, at time.Time) error
- func (m *MemoryStore) Update(_ context.Context, s *Share) error
- type MongoStore
- func (s *MongoStore) Create(ctx context.Context, sh *Share) error
- func (s *MongoStore) Delete(ctx context.Context, id string) error
- func (s *MongoStore) GetByID(ctx context.Context, id string) (*Share, error)
- func (s *MongoStore) ListByTenant(ctx context.Context, tenantID string) ([]*Share, error)
- func (s *MongoStore) ListDeliveries(ctx context.Context, shareID string, limit int) ([]*Delivery, error)
- func (s *MongoStore) RecordDelivery(ctx context.Context, d *Delivery) error
- func (s *MongoStore) Touch(ctx context.Context, id string, at time.Time) error
- func (s *MongoStore) Update(ctx context.Context, sh *Share) error
- type Service
- type Share
- type Store
Constants ¶
const TokenPrefix = "iws_"
TokenPrefix marks a config-share editor token in URLs / logs. The 32 random bytes follow the prefix; only the salted hash + last4 + fingerprint persist.
Variables ¶
var ErrNotFound = errors.New("configshare: not found")
ErrNotFound is returned by a Store when no share (or delivery) matches.
var ErrValidation = errors.New("configshare: invalid edit")
ErrValidation wraps a patch/field rejection (off-list path, bad value) so the caller can map it to a 400 while a forge/transport failure surfaces as a 502.
Functions ¶
func ApplyPatch ¶
func ApplyPatch(full, patch map[string]any, allowedPaths []string) (map[string]any, []string, error)
ApplyPatch validates a patch against allowedPaths (fail-closed) and deep-merges its leaves onto a COPY of full, leaving every unrelated field intact. Returns the merged config and the sorted changed dotted paths.
func DeriveGrant ¶
func DeriveGrant(editable, visible []string, category string, selectedFields ...string) (allowed []string, visibleOut []string, err error)
DeriveGrant expands a bot's declared config-share path templates (manifest config_share.editable_paths / visible_paths) for ONE category into the concrete (allowed, visible) dotted-path sets a Share pins at mint time. A "{category}" placeholder requires a well-formed, non-empty category and is substituted; templates without it pass through literally. The returned visible set is the union of the expanded editable + visible templates (VisiblePaths is a read superset of AllowedPaths), deduped, editable first.
selectedFields narrows the grant to a SUBSET of the bot's declared editable fields (by leaf name — least privilege per share): empty = the full declared editable surface; otherwise only the named fields, and every name MUST match a declared editable leaf (else ErrValidation). A non-selected editable field is neither writable nor visible (it drops out of both sets), keeping a feeds-only editor blind to the editorial prompt.
All failures wrap ErrValidation so the mint maps them to 400. The output is still run through ValidatePaths by the mint — DeriveGrant resolves the surface; ValidatePaths enforces the literal/no-overlap/no-forbidden rules.
func EnsureSchema ¶
EnsureSchema creates the config-share indexes idempotently: a unique token hash (the auth lookup), a per-tenant recent index (operator listing), and a TTL on the delivery audit rows.
func HasCategoryPlaceholder ¶
HasCategoryPlaceholder reports whether any of the given template sets carries the {category} placeholder — i.e. the bot's config-share surface is per-category, so a mint MUST supply a category.
func MintToken ¶
MintToken returns a fresh share token and the values persisted on the record (never the plaintext). Mirrors webhooks.MintToken so both self-authenticating surfaces share the same at-rest discipline.
func ProjectConfig ¶
ProjectConfig builds a FRESH object containing ONLY the visible dotted paths present in full — never a filtered pass over the original, so no unrelated category, field, or top-level key can leak on the wire. Missing paths are skipped, not errored.
func RepoSlug ¶
RepoSlug extracts the provider-native "owner/name" from a repo URL. Rejects a URL whose last two path segments aren't clean names, so a mis-stored RepoURL can't smuggle a path into the contents API call.
func ValidateConfigPath ¶
ValidateConfigPath requires a clean, repo-relative file path and refuses any traversal or a protected area (.git, .github/CI, Dockerfile, .env*) so a mis-minted share can never grant contents:write on CI or secrets.
func ValidateLeaf ¶
ValidateLeaf checks one edited leaf value against the constraints for its field (keyed by the last path segment). The two veille fields — feeds and editorial — carry the SSRF/size guards that keep a hostile editor's write from becoming an internal fetch or an oversized prompt; an unknown field passes structurally (the allow-list already gated its path).
func ValidatePaths ¶
ValidatePaths checks that a share's allowed/visible entries are literal dotted JSON paths — no globs, no malformed or forbidden segment — and that every writable path is also readable. Enforced at mint so an operator can't grant an unbounded or dangerous scope.
func ValidateRepoRef ¶
ValidateRepoRef requires an explicit, well-formed branch/ref — never empty (so a write can't default to an unexpected branch) and no leading dash (flag injection) or "..".
func VerifyToken ¶
VerifyToken constant-time compares a presented token against a stored hash.
Types ¶
type Delivery ¶
type Delivery struct {
ID string `json:"id" bson:"_id"`
TenantID string `json:"tenant_id" bson:"tenant_id"`
At time.Time `json:"at" bson:"at"`
SourceIP string `json:"source_ip" bson:"source_ip"`
UserAgent string `json:"user_agent" bson:"user_agent"`
Method string `json:"method" bson:"method"`
// Actor attributes the edit: "share:<id>" for a token (capability-URL)
// edit, "user:<id>" for an authenticated config-editor session (ADR-078).
// Empty on legacy rows.
Actor string `json:"actor,omitempty" bson:"actor,omitempty"`
Status int `json:"status" bson:"status"`
BeforeSHA string `json:"before_sha,omitempty" bson:"before_sha,omitempty"`
AfterSHA string `json:"after_sha,omitempty" bson:"after_sha,omitempty"`
ChangedPaths []string `json:"changed_paths,omitempty" bson:"changed_paths,omitempty"`
Error string `json:"error,omitempty" bson:"error,omitempty"`
}
Delivery is one audit row per mutating (and, optionally, reading) call through a share — the forensic trail after a token leak: who (source IP + UA), when, what changed (before/after blob SHA + the changed leaf paths).
type MemoryStore ¶
type MemoryStore struct {
// contains filtered or unexported fields
}
MemoryStore is an in-memory Store for desktop/local mode and tests. Cloud uses the Mongo store (multi-replica; survives ephemeral runner pods).
func NewMemoryStore ¶
func NewMemoryStore() *MemoryStore
NewMemoryStore returns an empty in-memory Store.
func (*MemoryStore) ListByTenant ¶
func (*MemoryStore) ListDeliveries ¶
func (*MemoryStore) RecordDelivery ¶
func (m *MemoryStore) RecordDelivery(_ context.Context, d *Delivery) error
type MongoStore ¶
type MongoStore struct {
// contains filtered or unexported fields
}
MongoStore is the cloud-mode config-share Store (persistent, multi-replica).
func NewMongoStore ¶
func NewMongoStore(db *mongo.Database) *MongoStore
NewMongoStore builds the store over a database.
func (*MongoStore) ListByTenant ¶
func (*MongoStore) ListDeliveries ¶
func (*MongoStore) RecordDelivery ¶
func (s *MongoStore) RecordDelivery(ctx context.Context, d *Delivery) error
type Service ¶
type Service struct {
Store Store
}
Service orchestrates a read/write through forge.FileClient over the pinned share record. It holds no forge credential — the caller mints a repo-scoped token and passes the matching FileClient, so token handling stays at the server layer where the forge connection lives.
func NewService ¶
NewService wires a Service over a share Store.
func (*Service) ApplyEdit ¶
func (svc *Service) ApplyEdit(ctx context.Context, fc forge.FileClient, sh *Share, patch map[string]any, expectSHA, message, authorName, authorEmail string) (string, []string, error)
ApplyEdit validates + merges a patch and writes it back with an if-match SHA. expectSHA is the SHA the editor read; a mismatch — or a concurrent change detected by the forge — surfaces forge.ErrFileConflict so the caller returns the fresh projection for a diff rather than overwriting. message/author are server-derived (never editor input). Returns the new SHA + changed paths.
func (*Service) ProjectedRead ¶
func (svc *Service) ProjectedRead(ctx context.Context, fc forge.FileClient, sh *Share) (map[string]any, string, error)
ProjectedRead reads the share's config file through fc and projects it to the share's visible paths — never the whole file. Returns the projection plus the current whole-file blob SHA (the if-match token for a later write).
type Share ¶
type Share struct {
// "categories.a11y.feeds"). No globs — every entry is a full leaf path.
AllowedPaths []string `json:"allowed_paths" bson:"allowed_paths"`
// VisiblePaths are the dotted paths the editor may READ back (a superset of
// AllowedPaths, plus read-only context like a category's digest_title). The
// GET projection returns ONLY these; everything else in the file is
// stripped before serialization.
}
Share is one config-edit grant. The token is stored only as a salted hash (+ last4/fingerprint for the operator UI); the plaintext is shown once at create/rotate. Every field that shapes a write — RepoURL, RepoRef, ConfigPath, AllowedPaths — is pinned here at mint time and never taken from a request body, so a token holder can't retarget the file, branch or fields.
type Store ¶
type Store interface {
Create(ctx context.Context, s *Share) error
GetByID(ctx context.Context, id string) (*Share, error)
ListByTenant(ctx context.Context, tenantID string) ([]*Share, error)
Update(ctx context.Context, s *Share) error
Delete(ctx context.Context, id string) error
Touch(ctx context.Context, id string, at time.Time) error
RecordDelivery(ctx context.Context, d *Delivery) error
ListDeliveries(ctx context.Context, shareID string, limit int) ([]*Delivery, error)
}
Store persists shares + their delivery audit, tenant-scoped. GetByID takes no tenant (the auth middleware resolves a share from the URL id before any tenant is known); operator CRUD handlers enforce tenancy via canManageTeam on the returned share's TenantID.