configshare

package
v1.7.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 22, 2026 License: MIT Imports: 20 Imported by: 0

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

View Source
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

View Source
var ErrNotFound = errors.New("configshare: not found")

ErrNotFound is returned by a Store when no share (or delivery) matches.

View Source
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

func EnsureSchema(ctx context.Context, db *mongo.Database) error

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

func HasCategoryPlaceholder(sets ...[]string) bool

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

func MintToken() (plaintext, hash, last4, fingerprint string, err error)

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

func ProjectConfig(full map[string]any, visiblePaths []string) map[string]any

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

func RepoSlug(repoURL string) (string, error)

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

func ValidateConfigPath(p string) error

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

func ValidateLeaf(dotted string, value any) error

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

func ValidatePaths(allowed, visible []string) error

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

func ValidateRepoRef(ref string) error

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

func VerifyToken(presented, storedHash string) bool

VerifyToken constant-time compares a presented token against a stored hash.

Types

type Delivery

type Delivery struct {
	ID        string    `json:"id" bson:"_id"`
	ShareID   string    `json:"share_id" bson:"share_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) Create

func (m *MemoryStore) Create(_ context.Context, s *Share) error

func (*MemoryStore) Delete

func (m *MemoryStore) Delete(_ context.Context, id string) error

func (*MemoryStore) GetByID

func (m *MemoryStore) GetByID(_ context.Context, id string) (*Share, error)

func (*MemoryStore) ListByTenant

func (m *MemoryStore) ListByTenant(_ context.Context, tenantID string) ([]*Share, error)

func (*MemoryStore) ListDeliveries

func (m *MemoryStore) ListDeliveries(_ context.Context, shareID string, limit int) ([]*Delivery, error)

func (*MemoryStore) RecordDelivery

func (m *MemoryStore) RecordDelivery(_ context.Context, d *Delivery) error

func (*MemoryStore) Touch

func (m *MemoryStore) Touch(_ context.Context, id string, at time.Time) error

func (*MemoryStore) Update

func (m *MemoryStore) Update(_ context.Context, s *Share) 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) Create

func (s *MongoStore) Create(ctx context.Context, sh *Share) error

func (*MongoStore) Delete

func (s *MongoStore) Delete(ctx context.Context, id string) error

func (*MongoStore) GetByID

func (s *MongoStore) GetByID(ctx context.Context, id string) (*Share, error)

func (*MongoStore) ListByTenant

func (s *MongoStore) ListByTenant(ctx context.Context, tenantID string) ([]*Share, error)

func (*MongoStore) ListDeliveries

func (s *MongoStore) ListDeliveries(ctx context.Context, shareID string, limit int) ([]*Delivery, error)

func (*MongoStore) RecordDelivery

func (s *MongoStore) RecordDelivery(ctx context.Context, d *Delivery) error

func (*MongoStore) Touch

func (s *MongoStore) Touch(ctx context.Context, id string, at time.Time) error

func (*MongoStore) Update

func (s *MongoStore) Update(ctx context.Context, sh *Share) 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

func NewService(store Store) *Service

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 {
	ID         string `json:"id" bson:"_id"`
	TenantID   string `json:"tenant_id" bson:"tenant_id"`
	BotID      string `json:"bot_id" bson:"bot_id"`
	Label      string `json:"label" bson:"label"`
	RepoURL    string `json:"repo_url" bson:"repo_url"`
	RepoRef    string `json:"repo_ref" bson:"repo_ref"`
	ConfigPath string `json:"config_path" bson:"config_path"`
	Category   string `json:"category,omitempty" bson:"category,omitempty"`
	SchemaRef  string `json:"schema_ref,omitempty" bson:"schema_ref,omitempty"`
	// AllowedPaths are literal dotted JSON paths the editor may WRITE (e.g.
	// "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.
	VisiblePaths []string `json:"visible_paths" bson:"visible_paths"`
	ReadOnly     bool     `json:"read_only" bson:"read_only"`

	TokenHash   string `json:"-" bson:"token_hash"`
	TokenLast4  string `json:"token_last4" bson:"token_last4"`
	Fingerprint string `json:"fingerprint" bson:"fingerprint"`

	Enabled       bool       `json:"enabled" bson:"enabled"`
	CreatedBy     string     `json:"created_by" bson:"created_by"`
	CreatedAt     time.Time  `json:"created_at" bson:"created_at"`
	ExpiresAt     time.Time  `json:"expires_at" bson:"expires_at"`
	RevokedAt     *time.Time `json:"revoked_at,omitempty" bson:"revoked_at,omitempty"`
	LastUsedAt    *time.Time `json:"last_used_at,omitempty" bson:"last_used_at,omitempty"`
	MonthlyWrites int        `json:"monthly_writes,omitempty" bson:"monthly_writes,omitempty"`
}

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.

func (*Share) Active

func (s *Share) Active(now time.Time) bool

Active reports whether the grant may authenticate at `now`: enabled, not revoked, and unexpired (a zero ExpiresAt never expires — mint always sets one).

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL