From 413084a9f927e75590c7c5d38cb9a2de40db161c Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:55:12 -0400 Subject: [PATCH 01/23] feat(accounts): the object-storage connector diffs the current listing against one the host retains An object store answers only "what is there now". objectstore.FilesConnector versions a source by the digest of its listing (key, ETag, size) under its binding, the host retains the listing each version names in datasource_object_manifests (migration 46, tenant-owned, newest four per source), and Changes(from) diffs against it. It reaches the store through a Bucket interface the object-storage service's per-call binding will implement (codefly-dev/service-object-storage#108), and passes connectortest.RunFiles over a scripted bucket. It is not yet what the host syncs through: the provider stays registered non-conformant and runUploadSync keeps running. The upload descriptor now states source-scoped readers in the catalog. Refs #1211 Co-Authored-By: Claude Opus 5.5 (1M context) --- module/DATABASE_AUTHORITY.md | 2 +- module/services/accounts/AGENTS.md | 34 ++ .../pkg/adapters/datasource_handler_test.go | 5 + .../pkg/business/datasource_connectors.go | 5 +- .../pkg/datasource/connector/CONFORMANCE.md | 49 ++- .../pkg/datasource/objectstore/connector.go | 384 ++++++++++++++++++ .../objectstore/connector_conformance_test.go | 253 ++++++++++++ .../pkg/infra/postgres_object_manifests.go | 58 +++ .../infra/postgres_object_manifests_test.go | 79 ++++ .../pkg/infra/postgres_role_hardening_test.go | 8 +- .../code/pkg/relationcatalog/catalog.go | 1 + .../46_datasource_object_manifests.down.sql | 1 + .../46_datasource_object_manifests.up.sql | 23 ++ 13 files changed, 887 insertions(+), 15 deletions(-) create mode 100644 module/services/accounts/code/pkg/datasource/objectstore/connector.go create mode 100644 module/services/accounts/code/pkg/datasource/objectstore/connector_conformance_test.go create mode 100644 module/services/accounts/code/pkg/infra/postgres_object_manifests.go create mode 100644 module/services/accounts/code/pkg/infra/postgres_object_manifests_test.go create mode 100644 module/services/store/migrations/46_datasource_object_manifests.down.sql create mode 100644 module/services/store/migrations/46_datasource_object_manifests.up.sql diff --git a/module/DATABASE_AUTHORITY.md b/module/DATABASE_AUTHORITY.md index c1aa31212..2b5bfbee3 100644 --- a/module/DATABASE_AUTHORITY.md +++ b/module/DATABASE_AUTHORITY.md @@ -526,7 +526,7 @@ executable inventory and this table in the same change. | Scope | Relations | Required database boundary | |---|---|---| | `global` | `audit_event_types`, `bootstrap_state`, `data_retention_policies`, `datasource_credential_budgets`, `email_templates`, `feature_flags`, `identity_providers`, `module_envelopes`, `plan_entitlements`, `plans`, `platform_admins`, `policy_log_commits`, `policy_log_cursor`, `solution_delivery_documents`, `solution_generation_history`, `solution_host_bindings`, `solution_registrations`, `solution_targets` | No RLS; exact grants | -| `tenant` | `actor_chain_journal`, `actor_chain_revocations`, `api_keys`, `approval_decisions`, `approval_requests`, `audit_event_idempotency`, `audit_events`, `connector_credentials`, `dashboards`, `datasource_account_links`, `datasource_domains`, `datasource_group_bindings`, `codefly_effect_receipts`, `datasource_operation_attempts`, `datasource_oauth_consumed_states`, `datasource_source_operations`, `datasource_sources`, `delegation_grants`, `domain_events`, `entitlement_overrides`, `executable_artifact_approvals`, `github_app_installations`, `github_app_setups`, `installations`, `invitations`, `membership_integrity_findings`, `org_generic_settings`, `org_identity_providers`, `org_key_bindings`, `org_settings`, `organization_activations`, `organization_authorization_revisions`, `organization_members`, `organizations`, `principal_authorization_revisions`, `principals`, `record_shares`, `role_assignments`, `role_permissions`, `roles`, `scope_grants`, `scope_nodes`, `source_delegations`, `source_read_revisions`, `subscriptions`, `team_members`, `team_membership_quarantine`, `teams`, `usage_events`, `usage_totals`, `webhook_deliveries`, `webhook_subscriptions`, `work_context_replay` | Enabled and forced RLS with at least one policy | +| `tenant` | `actor_chain_journal`, `actor_chain_revocations`, `api_keys`, `approval_decisions`, `approval_requests`, `audit_event_idempotency`, `audit_events`, `connector_credentials`, `dashboards`, `datasource_account_links`, `datasource_domains`, `datasource_group_bindings`, `datasource_object_manifests`, `codefly_effect_receipts`, `datasource_operation_attempts`, `datasource_oauth_consumed_states`, `datasource_source_operations`, `datasource_sources`, `delegation_grants`, `domain_events`, `entitlement_overrides`, `executable_artifact_approvals`, `github_app_installations`, `github_app_setups`, `installations`, `invitations`, `membership_integrity_findings`, `org_generic_settings`, `org_identity_providers`, `org_key_bindings`, `org_settings`, `organization_activations`, `organization_authorization_revisions`, `organization_members`, `organizations`, `principal_authorization_revisions`, `principals`, `record_shares`, `role_assignments`, `role_permissions`, `roles`, `scope_grants`, `scope_nodes`, `source_delegations`, `source_read_revisions`, `subscriptions`, `team_members`, `team_membership_quarantine`, `teams`, `usage_events`, `usage_totals`, `webhook_deliveries`, `webhook_subscriptions`, `work_context_replay` | Enabled and forced RLS with at least one policy | | `user` | `client_authorization_codes`, `gdpr_requests`, `mfa_backup_codes`, `mfa_devices`, `mfa_login_transactions`, `notifications`, `onboarding_progress`, `resource_follows`, `sessions`, `user_consent_events`, `user_consent_preferences`, `user_identities`, `users`, `webauthn_ceremonies`, `webauthn_credentials` | Enabled and forced RLS with at least one policy | | `pre_auth` | `magic_links`, `waitlist_entries` | Enabled and forced RLS; fail-closed request policy, accessed only by the control-plane role | | `job` | `job_messages` | Enabled and forced RLS with at least one policy; no request relation grant — function-only scoped enqueue plus exact job-worker grants | diff --git a/module/services/accounts/AGENTS.md b/module/services/accounts/AGENTS.md index efe99b0ec..ad4d78b08 100644 --- a/module/services/accounts/AGENTS.md +++ b/module/services/accounts/AGENTS.md @@ -1173,3 +1173,37 @@ cause (v2 of that type; `datasourceAccessLostCodes` is the whole vocabulary a registry test holds the declaration to). Recovery is the ordinary one — a later snapshot clearing the degrade — which a repository made public again, or reconnected with a PAT or through the App, all reach. + +## Object storage is a client of the object-storage service, diffed against a listing the host retains + +The `upload` provider — "Object storage" to a person — reads a bucket under a +key prefix into a `collection` scope node. Its readers are source-scoped: a +bucket has no per-object access list, so the model to offer is one collection +per prefix, granted like any scope. It is registered non-conformant, so +`AdmitNewSource` refuses a new one; existing sources sync on `runUploadSync` +over the host's own S3 client (`pkg/datasource/objectstore/client.go`), daily +and on "Sync now", re-sending every object and closing a whole-folder sync with +the listing the store tombstones the rest against. `AddSource` refuses a +webhook secret for it: there is no push path (#1211 b). + +The owner's direction (2026-10-21) is that the host **re-uses the composed +object-storage service** rather than keep a store client of its own: the +service takes a per-call bucket binding with the credential handed per call and +owns the public-destination guard and the size caps +(codefly-dev/service-object-storage#108). What lives here is what is per-source +state: + +- **The retained listing is the cursor.** `objectstore.FilesConnector` versions + a source by the digest of its listing under its binding and diffs the current + listing against the one a version names. The host keeps those listings in + `datasource_object_manifests` (migration 46), tenant-owned — the control plane + has no grant — and pruned to the newest four per source by + `PutDatasourceObjectManifest`; a pruned or rebound version resyncs. Clause by + clause, in [CONFORMANCE.md](./code/pkg/datasource/connector/CONFORMANCE.md). +- **The connector reaches the store through `objectstore.Bucket`.** It lists the + prefix whole or fails (`ErrListingIncomplete` — a partial listing would read + every unlisted object as deleted) and reads a batch in one call at the listed + ETags. The implementation over the service is not written yet; until it is, + the connector is exercised only by the conformance suite over a scripted + bucket, the provider stays registered non-conformant, and `client.go` stays + the running engine. It is deleted when the service client lands, not extended. diff --git a/module/services/accounts/code/pkg/adapters/datasource_handler_test.go b/module/services/accounts/code/pkg/adapters/datasource_handler_test.go index afbf185fb..fcd137495 100644 --- a/module/services/accounts/code/pkg/adapters/datasource_handler_test.go +++ b/module/services/accounts/code/pkg/adapters/datasource_handler_test.go @@ -60,6 +60,11 @@ func TestDatasourceCatalog_ProjectsTheRegistry(t *testing.T) { t.Fatalf("%s entry = %v, want listed, non-conformant, with a gap, accepting no new source", key, p) } } + // A bucket has no per-object access list; the catalog says so before a + // person connects one. + if r := byKey[business.DatasourceProviderUpload].GetReadersModel(); r != gen.DatasourceReadersModel_DATASOURCE_READERS_MODEL_SOURCE_SCOPED { + t.Fatalf("object storage readers model = %v, want source-scoped", r) + } if datasourceCatalog(nil).GetProviders() != nil { t.Fatal("an unconfigured connector offers nothing") } diff --git a/module/services/accounts/code/pkg/business/datasource_connectors.go b/module/services/accounts/code/pkg/business/datasource_connectors.go index 2ba1db5fc..269fea9c3 100644 --- a/module/services/accounts/code/pkg/business/datasource_connectors.go +++ b/module/services/accounts/code/pkg/business/datasource_connectors.go @@ -74,7 +74,10 @@ func (s *Service) newDatasourceConnectorRegistry() *connector.Registry { Description: "An S3-compatible bucket; objects under a prefix are pulled on sync.", Interface: connector.InterfaceFiles, CredentialModes: []connector.CredentialMode{connector.CredentialStaticSecret}, - Gap: gapUploadDatasource, + // A bucket has no per-object access list, so the catalog says before a + // person asks that every object is readable by the collection's readers. + Readers: connector.ReadersSourceScoped, + Gap: gapUploadDatasource, })) return r } diff --git a/module/services/accounts/code/pkg/datasource/connector/CONFORMANCE.md b/module/services/accounts/code/pkg/datasource/connector/CONFORMANCE.md index f175f11a8..17ff92a9c 100644 --- a/module/services/accounts/code/pkg/datasource/connector/CONFORMANCE.md +++ b/module/services/accounts/code/pkg/datasource/connector/CONFORMANCE.md @@ -54,13 +54,41 @@ closes a whole-folder sync. | 6 budget and backpressure | No declared `Budget`, and the connector is not metered by `datasource_credential_budgets`, so an object-storage sync cannot yield to a person's sync the way a GitHub one does. | | 8 bulk only | One `GetObject` per object. The listing is one call; the fetches are not. | -**What closing it takes.** A `FilesConnector` over S3 whose version is a digest -of the listing (key → ETag) and whose `Changes(from)` diffs the current listing -against the one `from` names — which needs the previous listing to be retained, -since S3 has no "what changed since" API. That is a store-shaped change, not -just a connector: the host would have to keep the last manifest per source. Not -done here because it is a design question about where that manifest lives, not a -gap in a flow that already exists. +**What closing it takes, and how far it has got.** The connector that closes +clause 4 exists and passes `connectortest.RunFiles` +(`pkg/datasource/objectstore/connector_conformance_test.go`); it is not yet what +the host syncs through, so the registration above is unchanged and every +object-storage source still runs on `runUploadSync`. + +- **The cursor is a retained listing.** An object store answers only "what is + there now", so `objectstore.FilesConnector` versions a source by a digest of + its listing (key, ETag, size) under its binding (endpoint, region, bucket, + prefix), and the host keeps the listing each version names in + `datasource_object_manifests` (migration 46; tenant-owned, the newest four + per source). `Changes(from)` diffs the current listing against the retained + one; a version whose listing was pruned, or that was listed under another + bucket or prefix, is `ErrResyncRequired`. A key that left and a key that + arrived with the same ETag and size are reported as one MOVED change — a + store has no rename, and the same bytes at a new key is what a move is. +- **A fetch is at a version.** `FetchFiles` serves an object only at the ETag + the version's listing names; an object rewritten or deleted since is + `ErrItemNotFound`, never content the version did not have. +- **The store is reached through a `Bucket`, not spoken to.** The connector + signs, dials and guards nothing: listing and reading are the composed + object-storage service's, under a per-call binding with the credential the + host decrypts (codefly-dev/service-object-storage#108). `client.go` in this + package is the engine `runUploadSync` still uses; it is deleted when the + `Bucket` over the service lands, not finished. +- **Clause 8 rests on the service.** The suite's scripted bucket serves a batch + in one read, which is the contract `Bucket.Read` states. Whether the + service's binding serves a batch in one call or one call per object decides + whether that holds against a real store; until the client exists it is the + stated requirement, not a measured fact. + +What remains before the provider is registered conformant: the `Bucket` over the +service, `runUploadSync` replaced by the envelope path, the connector registered +through `connector.Budgeted` so it is metered by `datasource_credential_budgets` +(clause 6), and the gap sentence above deleted. ## crawler — documentation website @@ -146,6 +174,7 @@ not qualify database commit/rollback or cross-process serialization. | api | no | yes (records) | no | no | no | Object storage is the nearest: it is already on an admissible interface and -already applies deletions, and one cursor stands between it and a second -conformant implementation. That is what would demonstrate the model generalises, -and it is the one worth doing next. +already applies deletions, and its cursor is built and passes the suite against +a scripted store. What stands between it and a second conformant implementation +is the client of the object-storage service the host syncs through. That is +what would demonstrate the model generalises. diff --git a/module/services/accounts/code/pkg/datasource/objectstore/connector.go b/module/services/accounts/code/pkg/datasource/objectstore/connector.go new file mode 100644 index 000000000..c5699f717 --- /dev/null +++ b/module/services/accounts/code/pkg/datasource/objectstore/connector.go @@ -0,0 +1,384 @@ +package objectstore + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "sort" + "strings" + "time" + + "accounts/pkg/datasource/connector" + accountsv1 "accounts/pkg/gen/saas/accounts/v1" +) + +// The object-storage files connector, on the envelope. An object store has no +// "what changed since": it answers only "what is there now". So the version of +// a source is a digest of its listing (key → ETag → size), the host retains the +// listing each version names (Manifests), and Changes diffs the current listing +// against the retained one. The listing and the reads go through a Bucket, which +// is the composed object-storage service under the source's binding +// (codefly-dev/service-object-storage#108): this connector signs nothing, dials +// nothing and guards nothing itself. + +// ConnectorKey is the provider key object-storage sources are stored under. +const ConnectorKey = "upload" + +// Budget limits. An object past MaxItemBytes is refused, never truncated. +const ( + MaxObjectsPerBatch = 1000 + MaxBatchBytes = 64 << 20 + MaxObjectBytes = 5 << 20 +) + +// estimatedThrottleWindow is the reset a throttled call is given when the store +// does not say when its limit resets. +const estimatedThrottleWindow = time.Minute + +// SourceConfig is where one source reads: its store, bucket and key prefix. The +// credential is not here; the host hands it to the Bucket per call. +type SourceConfig struct { + Endpoint string + Region string + Bucket string + Prefix string +} + +// binding names the configuration a version was listed under, so a version of +// one bucket or prefix is never diffed against, or read from, another. +func (c SourceConfig) binding() string { + sum := sha256.Sum256([]byte(c.Endpoint + "\x00" + c.Region + "\x00" + c.Bucket + "\x00" + c.Prefix)) + return hex.EncodeToString(sum[:8]) +} + +// Item is one object of a listing: its full key (the item's stable id), its +// ETag (the item's version) and its exact size in bytes. +type Item struct { + Key string `json:"key"` + ETag string `json:"etag"` + Size int64 `json:"size"` +} + +// ErrListingIncomplete: the store could not list the whole prefix (it truncated +// the listing, or the listing passed the store's cap). A partial listing is never +// a version: it would read every unlisted object as deleted. +var ErrListingIncomplete = errors.New("objectstore: the store did not list the whole prefix") + +// ErrObjectChanged: an object named at an ETag no longer has it (it was +// rewritten or deleted after it was listed). +var ErrObjectChanged = errors.New("objectstore: the object is no longer at the listed version") + +// Bucket is the store as the connector reads it, for one source. It reports a +// throttle as *connector.RateLimitedError and a refused credential as +// connector.ErrCredentialRefused. +type Bucket interface { + // List returns every object under the source's prefix, or + // ErrListingIncomplete; never a partial listing. + List(ctx context.Context) ([]Item, error) + // Read streams the named objects in one call, in order, each only at its + // listed ETag (ErrObjectChanged otherwise), with a reader of exactly its size. + Read(ctx context.Context, items []Item, visit func(item Item, contentType string, content io.Reader) error) error +} + +// BucketFunc resolves the Bucket of one source, per call: the host decrypts the +// source's credential and binds it, and the connector never sees it. +type BucketFunc func(ctx context.Context, src connector.Source) (Bucket, error) + +// Manifests is the host's retained listings, per source and version. Put is +// idempotent; Get reports false for a version that was never retained or has +// since been pruned. +type Manifests interface { + Put(ctx context.Context, src connector.Source, version string, items []Item) error + Get(ctx context.Context, src connector.Source, version string) ([]Item, bool, error) +} + +// FilesConnector is the object-storage connector. +type FilesConnector struct { + bucket BucketFunc + manifests Manifests + budget connector.Budget + now func() time.Time +} + +// ConnectorOption configures a FilesConnector. +type ConnectorOption func(*FilesConnector) + +// WithMaxItemBytes lowers the per-object limit, for a test that must script an +// over-limit object cheaply. It never raises it past MaxObjectBytes. +func WithMaxItemBytes(n int64) ConnectorOption { + return func(c *FilesConnector) { + if n > 0 && n < c.budget.MaxItemBytes { + c.budget.MaxItemBytes = n + } + } +} + +// NewFilesConnector returns the object-storage connector over bucket, retaining +// listings in manifests. +func NewFilesConnector(bucket BucketFunc, manifests Manifests, opts ...ConnectorOption) *FilesConnector { + c := &FilesConnector{ + bucket: bucket, + manifests: manifests, + budget: connector.Budget{ + MaxItemsPerCall: MaxObjectsPerBatch, MaxBytesPerCall: MaxBatchBytes, MaxItemBytes: MaxObjectBytes, + // One operation is one listing or one batched read. A store meters + // requests per second, far above this; the quota is what the host + // spends on a tenant's bucket, with a fifth kept for a person's sync. + OperationsPerWindow: 1200, Window: time.Hour, BackgroundSharePercent: 80, + }, + now: time.Now, + } + for _, opt := range opts { + opt(c) + } + return c +} + +// Descriptor is the connector's catalog entry. +func (c *FilesConnector) Descriptor() connector.Descriptor { + return connector.Descriptor{ + Key: ConnectorKey, + DisplayName: "Object storage", + Description: "A bucket in an object store; the objects under a key prefix are pulled on sync, and only what changed is re-sent.", + Interface: connector.InterfaceFiles, + ConfigFields: []connector.ConfigField{ + {Key: "endpoint", DisplayName: "Endpoint", Help: "The store's https base URL.", Required: true}, + {Key: "region", DisplayName: "Region", Help: "The bucket's region, e.g. us-east-1.", Required: true}, + {Key: "bucket", DisplayName: "Bucket", Required: true}, + {Key: "prefix", DisplayName: "Prefix", Help: "Key prefix to read under; empty means the whole bucket.", Required: false}, + {Key: "access_key_id", DisplayName: "Access key ID", Required: true}, + }, + CredentialModes: []connector.CredentialMode{connector.CredentialStaticSecret}, + // A bucket has no per-object access list: every object is readable by + // whoever may read the source's collection. One collection per prefix. + Readers: connector.ReadersSourceScoped, + Budget: c.budget, + Conformant: true, + } +} + +func (c *FilesConnector) open(ctx context.Context, src connector.Source) (SourceConfig, Bucket, error) { + cfg, ok := src.Config.(SourceConfig) + if !ok || cfg.Bucket == "" { + return SourceConfig{}, nil, fmt.Errorf("objectstore connector: source %s has no object-storage configuration", src.ID) + } + if err := ctx.Err(); err != nil { + return SourceConfig{}, nil, err + } + bucket, err := c.bucket(ctx, src) + if err != nil { + return SourceConfig{}, nil, c.envelopeError(err) + } + return cfg, bucket, nil +} + +// list reads the current listing and its version. Folder markers (keys ending +// in "/") are not files and are left out. +func (c *FilesConnector) list(ctx context.Context, cfg SourceConfig, bucket Bucket) (string, map[string]Item, error) { + items, err := bucket.List(ctx) + if err != nil { + return "", nil, c.envelopeError(err) + } + listed := make(map[string]Item, len(items)) + for _, it := range items { + if !strings.HasSuffix(it.Key, "/") { + listed[it.Key] = it + } + } + return versionOf(cfg, listed), listed, nil +} + +// versionOf is the listing's digest under its binding: the binding's digest, +// then the digest of every key, ETag and size in key order. +func versionOf(cfg SourceConfig, listed map[string]Item) string { + h := sha256.New() + for _, key := range sortedKeys(listed) { + it := listed[key] + fmt.Fprintf(h, "%s\x00%s\x00%d\n", it.Key, it.ETag, it.Size) + } + return cfg.binding() + "." + hex.EncodeToString(h.Sum(nil)) +} + +func sortedKeys(m map[string]Item) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// Version lists the prefix and digests it. An object store answers no cheaper +// question than its listing, so this costs one listing call. +func (c *FilesConnector) Version(ctx context.Context, src connector.Source) (string, error) { + cfg, bucket, err := c.open(ctx, src) + if err != nil { + return "", err + } + version, _, err := c.list(ctx, cfg, bucket) + return version, err +} + +// Changes diffs the current listing against the retained listing from names, +// and retains the current one under its version. An empty from is a complete +// snapshot. A from that was listed under another binding, or whose listing is +// no longer retained, requires a resync. +// +// An object store has no rename: a key that disappeared and a key that appeared +// with the same ETag and size are the same content moved, and are reported as +// one MOVED change, paired in key order. +func (c *FilesConnector) Changes(ctx context.Context, src connector.Source, from string) (connector.ChangeSet, error) { + cfg, bucket, err := c.open(ctx, src) + if err != nil { + return connector.ChangeSet{}, err + } + var previous map[string]Item + if from != "" { + if !strings.HasPrefix(from, cfg.binding()+".") { + return connector.ChangeSet{}, connector.ErrResyncRequired + } + items, ok, err := c.manifests.Get(ctx, src, from) + if err != nil { + return connector.ChangeSet{}, err + } + if !ok { + return connector.ChangeSet{}, connector.ErrResyncRequired + } + previous = make(map[string]Item, len(items)) + for _, it := range items { + previous[it.Key] = it + } + } + version, listed, err := c.list(ctx, cfg, bucket) + if err != nil { + return connector.ChangeSet{}, err + } + set := connector.ChangeSet{SourceID: src.ID, From: from, To: version, Complete: from == ""} + if version == from { + return set, nil + } + current := make([]Item, 0, len(listed)) + for _, key := range sortedKeys(listed) { + current = append(current, listed[key]) + } + if err := c.manifests.Put(ctx, src, version, current); err != nil { + return connector.ChangeSet{}, err + } + readers := connector.ApplySourcePolicy(src, connector.SourceScoped()) + change := func(it Item, kind connector.ChangeKind) connector.Change { + return connector.Change{Key: connector.KeyFor(src, it.Key), Kind: kind, ItemVersion: it.ETag, Size: it.Size, Locator: it.Key, Readers: readers} + } + var added, deleted []Item + for _, it := range current { + prev, ok := previous[it.Key] + switch { + case !ok: + added = append(added, it) + case prev.ETag != it.ETag || prev.Size != it.Size: + set.Changes = append(set.Changes, change(it, connector.ChangeModified)) + } + } + for _, key := range sortedKeys(previous) { + if _, ok := listed[key]; !ok { + deleted = append(deleted, previous[key]) + } + } + moved := make(map[string]bool) + for _, it := range added { + kind, prevKey := connector.ChangeAdded, "" + for _, gone := range deleted { + if !moved[gone.Key] && gone.ETag == it.ETag && gone.Size == it.Size { + moved[gone.Key], kind, prevKey = true, connector.ChangeMoved, gone.Key + break + } + } + ch := change(it, kind) + ch.PreviousItemID = prevKey + set.Changes = append(set.Changes, ch) + } + for _, gone := range deleted { + if !moved[gone.Key] { + set.Changes = append(set.Changes, connector.Change{Key: connector.KeyFor(src, gone.Key), Kind: connector.ChangeDeleted, Locator: gone.Key}) + } + } + return set, nil +} + +// FetchFiles reads the named objects as the retained listing of version names +// them, in one batched read. An object rewritten or deleted since that listing +// is not at that version any more, and is ErrItemNotFound. +func (c *FilesConnector) FetchFiles(ctx context.Context, src connector.Source, version string, refs []connector.FileRef, visit func(connector.File, io.Reader) error) error { + if len(refs) == 0 || len(refs) > c.budget.MaxItemsPerCall { + return fmt.Errorf("%w: a batch names 1 to %d objects", connector.ErrBatchTooLarge, c.budget.MaxItemsPerCall) + } + seen := make(map[string]bool, len(refs)) + for _, ref := range refs { + if ref.ItemID == "" || seen[ref.ItemID] { + return errors.New("objectstore connector: every object of a batch names a distinct item") + } + seen[ref.ItemID] = true + } + cfg, bucket, err := c.open(ctx, src) + if err != nil { + return err + } + if !strings.HasPrefix(version, cfg.binding()+".") { + return connector.ErrVersionNotFound + } + retained, ok, err := c.manifests.Get(ctx, src, version) + if err != nil { + return err + } + if !ok { + return connector.ErrVersionNotFound + } + byKey := make(map[string]Item, len(retained)) + for _, it := range retained { + byKey[it.Key] = it + } + items := make([]Item, len(refs)) + var total int64 + for i, ref := range refs { + it, ok := byKey[ref.ItemID] + if !ok || (ref.ItemVersion != "" && ref.ItemVersion != it.ETag) { + return connector.ErrItemNotFound + } + if it.Size > c.budget.MaxItemBytes { + return fmt.Errorf("%w: an object is over %d bytes", connector.ErrItemTooLarge, c.budget.MaxItemBytes) + } + items[i], total = it, total+it.Size + } + if total > c.budget.MaxBytesPerCall { + return fmt.Errorf("%w: the batch is %d bytes, over %d; request fewer objects", connector.ErrBatchTooLarge, total, c.budget.MaxBytesPerCall) + } + readers := connector.ApplySourcePolicy(src, connector.SourceScoped()) + err = bucket.Read(ctx, items, func(it Item, contentType string, content io.Reader) error { + return visit(connector.File{ + Provenance: &accountsv1.DatasourceProvenance{ + SourceId: src.ID, OrgId: src.OrgID, BoundaryNodeId: src.BoundaryNodeID, + Version: version, ItemId: it.Key, ItemVersion: it.ETag, + }, + Locator: it.Key, + ContentType: contentType, + Size: it.Size, + Readers: readers, + }, content) + }) + if errors.Is(err, ErrObjectChanged) { + return fmt.Errorf("%w: %w", connector.ErrItemNotFound, err) + } + return c.envelopeError(err) +} + +// envelopeError gives a throttle the store did not time a reset, keeping every +// other error, typed by the Bucket, as it is. +func (c *FilesConnector) envelopeError(err error) error { + var limited *connector.RateLimitedError + if errors.As(err, &limited) && limited.ResetAt.IsZero() { + limited.ResetAt, limited.Estimated = c.now().Add(estimatedThrottleWindow), true + } + return err +} diff --git a/module/services/accounts/code/pkg/datasource/objectstore/connector_conformance_test.go b/module/services/accounts/code/pkg/datasource/objectstore/connector_conformance_test.go new file mode 100644 index 000000000..568c485ff --- /dev/null +++ b/module/services/accounts/code/pkg/datasource/objectstore/connector_conformance_test.go @@ -0,0 +1,253 @@ +package objectstore + +import ( + "context" + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + "io" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "accounts/pkg/datasource/connector" + "accounts/pkg/datasource/connector/connectortest" +) + +// TestFilesConnectorConformance runs the datasource conformance suite against +// the object-storage connector, over a scripted bucket that requires a +// sentinel credential and counts every call: one per listing, one per batched +// read, which is the contract Bucket states. +func TestFilesConnectorConformance(t *testing.T) { + connectortest.RunFiles(t, newConformanceFixture) +} + +const conformanceCredential = "sentinel-secret-key-4c1d" +const conformanceMaxItemBytes = 4 << 10 + +var conformanceConfig = SourceConfig{Endpoint: "https://store.example.com", Region: "us-east-1", Bucket: "acme-docs", Prefix: "kb/"} + +type conformanceFixture struct { + store *fakeStore + manifests *memoryManifests + conn *FilesConnector + sources int +} + +func newConformanceFixture(t *testing.T) connectortest.FilesFixture { + f := &conformanceFixture{store: &fakeStore{objects: map[string]string{}}, manifests: &memoryManifests{}} + f.conn = NewFilesConnector(func(context.Context, connector.Source) (Bucket, error) { + // The host resolves the credential per call; here it is the sentinel. + return &fakeBucket{store: f.store, credential: conformanceCredential}, nil + }, f.manifests, WithMaxItemBytes(conformanceMaxItemBytes)) + return f +} + +func (f *conformanceFixture) Connector() connector.FilesConnector { return f.conn } + +func (f *conformanceFixture) Source(t *testing.T, org string) connector.Source { + f.sources++ + return connector.Source{ + ID: fmt.Sprintf("aaaaaaaa-0000-0000-0000-%012d", f.sources), + OrgID: org, + BoundaryNodeID: fmt.Sprintf("bbbbbbbb-0000-0000-0000-%012d", f.sources), + Config: conformanceConfig, + } +} + +// Apply writes the provider's next state. A store has no rename: a move is the +// object written at its new key and removed from its old one. +func (f *conformanceFixture) Apply(t *testing.T, puts map[string]string, deletes []string, moves map[string]string) { + f.store.mu.Lock() + defer f.store.mu.Unlock() + for key, content := range puts { + f.store.objects[key] = content + } + for _, key := range deletes { + delete(f.store.objects, key) + } + for from, to := range moves { + f.store.objects[to] = f.store.objects[from] + delete(f.store.objects, from) + } +} + +// Rewrite: a bucket has no history to rewrite. What makes a version +// undiffable is that the listing it names is no longer retained, so the +// fixture drops every retained listing. +func (f *conformanceFixture) Rewrite(t *testing.T) { f.manifests.forget() } + +func (f *conformanceFixture) ProviderCalls() int64 { return f.store.calls.Load() } + +func (f *conformanceFixture) RateLimit(t *testing.T) func() { + f.store.throttled.Store(true) + return func() { f.store.throttled.Store(false) } +} + +func (f *conformanceFixture) Credential() string { return conformanceCredential } + +func (f *conformanceFixture) CredentialPresented() bool { return f.store.authenticated.Load() } + +func (f *conformanceFixture) MaxItemBytes() int64 { return conformanceMaxItemBytes } + +// fakeStore is one bucket's objects, shared by every source of a fixture. +type fakeStore struct { + mu sync.Mutex + objects map[string]string + calls atomic.Int64 + throttled atomic.Bool + authenticated atomic.Bool +} + +func etagOf(content string) string { + sum := md5.Sum([]byte(content)) + return `"` + hex.EncodeToString(sum[:]) + `"` +} + +// fakeBucket is the store as one call's credential sees it. +type fakeBucket struct { + store *fakeStore + credential string +} + +var errDenied = errors.New("fake store: access denied") + +func (b *fakeBucket) call(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + b.store.calls.Add(1) + if b.credential != conformanceCredential { + return fmt.Errorf("%w: %w", connector.ErrCredentialRefused, errDenied) + } + b.store.authenticated.Store(true) + if b.store.throttled.Load() { + // SlowDown carries no reset; the connector estimates one. + return &connector.RateLimitedError{Scope: "credential", Cause: errors.New("fake store: SlowDown")} + } + return nil +} + +func (b *fakeBucket) List(ctx context.Context) ([]Item, error) { + if err := b.call(ctx); err != nil { + return nil, err + } + b.store.mu.Lock() + defer b.store.mu.Unlock() + items := make([]Item, 0, len(b.store.objects)) + for key, content := range b.store.objects { + items = append(items, Item{Key: key, ETag: etagOf(content), Size: int64(len(content))}) + } + return items, nil +} + +func (b *fakeBucket) Read(ctx context.Context, items []Item, visit func(Item, string, io.Reader) error) error { + if err := b.call(ctx); err != nil { + return err + } + b.store.mu.Lock() + contents := make([]string, len(items)) + for i, it := range items { + content, ok := b.store.objects[it.Key] + if !ok || etagOf(content) != it.ETag { + b.store.mu.Unlock() + return ErrObjectChanged + } + contents[i] = content + } + b.store.mu.Unlock() + for i, it := range items { + if err := visit(it, "text/plain; charset=utf-8", strings.NewReader(contents[i])); err != nil { + return err + } + } + return nil +} + +// memoryManifests retains every listing until forget. +type memoryManifests struct { + mu sync.Mutex + kept map[string][]Item +} + +func (m *memoryManifests) Put(_ context.Context, src connector.Source, version string, items []Item) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.kept == nil { + m.kept = map[string][]Item{} + } + m.kept[src.ID+"/"+version] = append([]Item(nil), items...) + return nil +} + +func (m *memoryManifests) Get(_ context.Context, src connector.Source, version string) ([]Item, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + items, ok := m.kept[src.ID+"/"+version] + return items, ok, nil +} + +func (m *memoryManifests) forget() { + m.mu.Lock() + defer m.mu.Unlock() + m.kept = nil +} + +// TestChangesResyncAcrossABinding holds a version to the bucket and prefix it +// was listed under: after the source is pointed elsewhere, its old version is +// neither diffed against nor read from. +func TestChangesResyncAcrossABinding(t *testing.T) { + f := newConformanceFixture(t).(*conformanceFixture) + f.Apply(t, map[string]string{"kb/a.md": "alpha"}, nil, nil) + src := f.Source(t, "11111111-1111-1111-1111-111111111111") + ctx := context.Background() + a, err := f.conn.Changes(ctx, src, "") + if err != nil { + t.Fatal(err) + } + moved := conformanceConfig + moved.Prefix = "other/" + src.Config = moved + if _, err := f.conn.Changes(ctx, src, a.To); !errors.Is(err, connector.ErrResyncRequired) { + t.Fatalf("changes from a version of another prefix = %v, want ErrResyncRequired", err) + } + err = f.conn.FetchFiles(ctx, src, a.To, []connector.FileRef{{ItemID: "kb/a.md"}}, func(connector.File, io.Reader) error { return nil }) + if !errors.Is(err, connector.ErrVersionNotFound) { + t.Fatalf("fetching a version of another prefix = %v, want ErrVersionNotFound", err) + } +} + +// TestFetchRefusesARewrittenObject: an object rewritten after it was listed is +// not at the listed version any more, so a fetch at that version refuses it +// rather than serve content the version never had. +func TestFetchRefusesARewrittenObject(t *testing.T) { + f := newConformanceFixture(t).(*conformanceFixture) + f.Apply(t, map[string]string{"kb/a.md": "alpha"}, nil, nil) + src := f.Source(t, "11111111-1111-1111-1111-111111111111") + ctx := context.Background() + a, err := f.conn.Changes(ctx, src, "") + if err != nil { + t.Fatal(err) + } + f.Apply(t, map[string]string{"kb/a.md": "alpha, rewritten"}, nil, nil) + err = f.conn.FetchFiles(ctx, src, a.To, []connector.FileRef{{ItemID: "kb/a.md"}}, func(connector.File, io.Reader) error { return nil }) + if !errors.Is(err, connector.ErrItemNotFound) { + t.Fatalf("fetching an object rewritten since its listing = %v, want ErrItemNotFound", err) + } +} + +// TestRateLimitIsEstimatedWhenTheStoreGivesNoReset: a store's throttle names +// no reset, so the connector gives it one rather than a zero time. +func TestRateLimitIsEstimatedWhenTheStoreGivesNoReset(t *testing.T) { + f := newConformanceFixture(t).(*conformanceFixture) + clear := f.RateLimit(t) + defer clear() + _, err := f.conn.Changes(context.Background(), f.Source(t, "11111111-1111-1111-1111-111111111111"), "") + var limited *connector.RateLimitedError + if !errors.As(err, &limited) || !limited.Estimated || !limited.ResetAt.After(time.Now()) { + t.Fatalf("a throttle without a reset = %v, want an estimated reset in the future", err) + } +} diff --git a/module/services/accounts/code/pkg/infra/postgres_object_manifests.go b/module/services/accounts/code/pkg/infra/postgres_object_manifests.go new file mode 100644 index 000000000..6d3abdbf8 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/postgres_object_manifests.go @@ -0,0 +1,58 @@ +package infra + +import ( + "context" + "encoding/json" + "errors" + + "github.com/jackc/pgx/v5" + + "accounts/pkg/datasource/objectstore" +) + +// retainedObjectManifests is how many listings a source keeps: the one its +// cursor names, the one a sync in flight just listed, and slack for a sync that +// listed and then failed before the cursor moved. A version past it resyncs. +const retainedObjectManifests = 4 + +// PutDatasourceObjectManifest retains one listing of an object-storage source +// under its version, inside the source's org transaction, and prunes all but +// the newest retained listings. A version is the listing's own digest, so a +// second put of it only marks it newest. +func (s *PostgresStore) PutDatasourceObjectManifest(ctx context.Context, org, source, version string, objects []objectstore.Item) error { + if objects == nil { + objects = []objectstore.Item{} + } + encoded, err := json.Marshal(objects) + if err != nil { + return err + } + q := s.getQueryExecutor(ctx) + if _, err := q.Exec(ctx, `INSERT INTO datasource_object_manifests (org_id,source_id,version,objects,retained_at) VALUES ($1,$2,$3,$4,clock_timestamp()) + ON CONFLICT (org_id,source_id,version) DO UPDATE SET retained_at = clock_timestamp()`, org, source, version, encoded); err != nil { + return err + } + _, err = q.Exec(ctx, `DELETE FROM datasource_object_manifests WHERE org_id=$1 AND source_id=$2 AND version NOT IN ( + SELECT version FROM datasource_object_manifests WHERE org_id=$1 AND source_id=$2 ORDER BY retained_at DESC, version LIMIT $3)`, + org, source, retainedObjectManifests) + return err +} + +// GetDatasourceObjectManifest returns the listing a source retained under +// version, and false when it never retained one or has pruned it. +func (s *PostgresStore) GetDatasourceObjectManifest(ctx context.Context, org, source, version string) ([]objectstore.Item, bool, error) { + var encoded []byte + err := s.getQueryExecutor(ctx).QueryRow(ctx, `SELECT objects FROM datasource_object_manifests WHERE org_id=$1 AND source_id=$2 AND version=$3`, + org, source, version).Scan(&encoded) + if errors.Is(err, pgx.ErrNoRows) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + var objects []objectstore.Item + if err := json.Unmarshal(encoded, &objects); err != nil { + return nil, false, err + } + return objects, true, nil +} diff --git a/module/services/accounts/code/pkg/infra/postgres_object_manifests_test.go b/module/services/accounts/code/pkg/infra/postgres_object_manifests_test.go new file mode 100644 index 000000000..7658a564f --- /dev/null +++ b/module/services/accounts/code/pkg/infra/postgres_object_manifests_test.go @@ -0,0 +1,79 @@ +//go:build !pure + +package infra_test + +import ( + "context" + "fmt" + "strings" + "testing" + + "accounts/pkg/datasource/objectstore" + + "github.com/stretchr/testify/require" +) + +func manifestVersion(i int) string { + return fmt.Sprintf("%016x.%064x", 0xabc, i) +} + +// TestPostgresDatasourceObjectManifestsRetainTheNewest pins the retained +// listings an object-storage source diffs against: a listing round-trips under +// its version, a source keeps only its newest few, a re-put of a version makes +// it newest again, and another organization reads none of them. +func TestPostgresDatasourceObjectManifestsRetainTheNewest(t *testing.T) { + userID := seedUser(t) + orgID := seedOrg(t, userID) + sourceID := seedDatasourceSource(t, orgID) + listing := []objectstore.Item{{Key: "kb/a.md", ETag: `"e1"`, Size: 5}, {Key: "kb/b.md", ETag: `"e2"`, Size: 7}} + + put := func(i int) { + require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { + return testStore.PutDatasourceObjectManifest(ctx, orgID, sourceID, manifestVersion(i), listing) + })) + } + get := func(org string, i int) ([]objectstore.Item, bool) { + var items []objectstore.Item + var ok bool + require.NoError(t, testStore.WithOrgTx(testCtx, org, func(ctx context.Context) error { + var err error + items, ok, err = testStore.GetDatasourceObjectManifest(ctx, orgID, sourceID, manifestVersion(i)) + return err + })) + return items, ok + } + + put(0) + items, ok := get(orgID, 0) + require.True(t, ok) + require.Equal(t, listing, items) + + for i := 1; i <= 3; i++ { + put(i) + } + put(0) // re-listed: newest again, so version 1 is now the oldest + put(4) + _, ok = get(orgID, 1) + require.False(t, ok, "the oldest listing past the retained count is pruned") + for _, i := range []int{0, 2, 3, 4} { + _, ok = get(orgID, i) + require.True(t, ok, "listing %d is among the newest and must be retained", i) + } + + otherOrg := seedOrg(t, seedUser(t)) + _, ok = get(otherOrg, 4) + require.False(t, ok, "another organization must not read a source's listings") +} + +// TestPostgresDatasourceObjectManifestRefusesAMalformedVersion: the version is +// the connector's digest, never a free string. +func TestPostgresDatasourceObjectManifestRefusesAMalformedVersion(t *testing.T) { + userID := seedUser(t) + orgID := seedOrg(t, userID) + sourceID := seedDatasourceSource(t, orgID) + err := testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { + return testStore.PutDatasourceObjectManifest(ctx, orgID, sourceID, "not-a-version", nil) + }) + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), "check"), "want the version CHECK to refuse it, got %v", err) +} diff --git a/module/services/accounts/code/pkg/infra/postgres_role_hardening_test.go b/module/services/accounts/code/pkg/infra/postgres_role_hardening_test.go index ab73fe465..a83194110 100644 --- a/module/services/accounts/code/pkg/infra/postgres_role_hardening_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_role_hardening_test.go @@ -85,6 +85,7 @@ var appTenantRelationPrivileges = map[string]relationPrivileges{ "installations": {selectRows: true, insertRows: true, updateRows: true, deleteRows: true}, "datasource_sources": {selectRows: true, insertRows: true, updateRows: true, deleteRows: true}, "datasource_source_operations": {selectRows: true, insertRows: true, updateRows: true, deleteRows: true}, + "datasource_object_manifests": {selectRows: true, insertRows: true, updateRows: true, deleteRows: true}, "datasource_operation_attempts": {selectRows: true, insertRows: true, deleteRows: true}, "codefly_effect_receipts": {selectRows: true, insertRows: true, deleteRows: true}, "datasource_oauth_consumed_states": {selectRows: true, insertRows: true, deleteRows: true}, @@ -371,11 +372,12 @@ func TestControlPlaneRelationGrantsAreExact(t *testing.T) { if relation == "executable_artifact_approvals" { want = relationPrivileges{} } - // Callable-source declarations, OAuth state, attempts and receipts - // are tenant-only. The control plane must not read or mutate them. + // Callable-source declarations, OAuth state, attempts, receipts and + // retained object listings are tenant-only. The control plane must not read or mutate them. switch relation { case "datasource_source_operations", "datasource_oauth_consumed_states", - "datasource_operation_attempts", "codefly_effect_receipts": + "datasource_operation_attempts", "codefly_effect_receipts", + "datasource_object_manifests": want = relationPrivileges{} } if relation == "feature_flags" { diff --git a/module/services/accounts/code/pkg/relationcatalog/catalog.go b/module/services/accounts/code/pkg/relationcatalog/catalog.go index 2151dc06d..ab2c23d56 100644 --- a/module/services/accounts/code/pkg/relationcatalog/catalog.go +++ b/module/services/accounts/code/pkg/relationcatalog/catalog.go @@ -141,6 +141,7 @@ var authorities = map[string]Authority{ "dashboards": {Scope: ScopeTenant, PolicyShape: ShapeDirect, ScopeColumn: "org_id"}, "datasource_sources": {Scope: ScopeTenant, PolicyShape: ShapeDirect, ScopeColumn: "org_id"}, "datasource_source_operations": {Scope: ScopeTenant, PolicyShape: ShapeDirect, ScopeColumn: "org_id"}, + "datasource_object_manifests": {Scope: ScopeTenant, PolicyShape: ShapeDirect, ScopeColumn: "org_id"}, "datasource_operation_attempts": {Scope: ScopeTenant, PolicyShape: ShapeDirect, ScopeColumn: "org_id"}, "codefly_effect_receipts": {Scope: ScopeTenant, PolicyShape: ShapeDirect, ScopeColumn: "tenant"}, "datasource_oauth_consumed_states": {Scope: ScopeTenant, PolicyShape: ShapeDirect, ScopeColumn: "org_id"}, diff --git a/module/services/store/migrations/46_datasource_object_manifests.down.sql b/module/services/store/migrations/46_datasource_object_manifests.down.sql new file mode 100644 index 000000000..07c5fa13e --- /dev/null +++ b/module/services/store/migrations/46_datasource_object_manifests.down.sql @@ -0,0 +1 @@ +DROP TABLE public.datasource_object_manifests; diff --git a/module/services/store/migrations/46_datasource_object_manifests.up.sql b/module/services/store/migrations/46_datasource_object_manifests.up.sql new file mode 100644 index 000000000..b68f79517 --- /dev/null +++ b/module/services/store/migrations/46_datasource_object_manifests.up.sql @@ -0,0 +1,23 @@ +-- The listings an object-storage source was synced at. An object store answers +-- only "what is there now", so the host keeps the listing each version names: +-- the next sync diffs the current listing against it, and a module fetches an +-- object at the version it was told of. The version is the listing's own +-- digest under the source's binding, so a row is written once and never +-- changes; only the newest few per source are kept. Tenant-owned: the control +-- plane neither reads nor writes it. +CREATE TABLE public.datasource_object_manifests ( + org_id uuid NOT NULL, + source_id uuid NOT NULL, + version text NOT NULL CHECK (version ~ '^[a-f0-9]{16}\.[a-f0-9]{64}$'), + objects jsonb NOT NULL CHECK (jsonb_typeof(objects) = 'array'), + retained_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (org_id,source_id) REFERENCES public.datasource_sources(org_id,id) ON DELETE CASCADE, + PRIMARY KEY (org_id,source_id,version) +); +CREATE INDEX datasource_object_manifests_retained ON public.datasource_object_manifests (org_id,source_id,retained_at DESC); +ALTER TABLE public.datasource_object_manifests ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.datasource_object_manifests FORCE ROW LEVEL SECURITY; +CREATE POLICY datasource_object_manifests_tenant ON public.datasource_object_manifests TO app_tenant + USING (org_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid) + WITH CHECK (org_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid); +GRANT SELECT, INSERT, UPDATE, DELETE ON public.datasource_object_manifests TO app_tenant; From 7c53397525f216f0bcc76afa5e8a7feaacc2a63c Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:40:24 -0400 Subject: [PATCH 02/23] feat(accounts,saas-ui): a bucket source takes include and exclude key patterns, applied before any fetch UploadDatasourceConfig gains include_patterns and exclude_patterns (bounded RE2 over the key relative to the prefix; exclude wins; empty include admits all) and file_extensions with GitHub's semantics. AddSource and UpdateSource refuse an invalid pattern by field (INVALID_ARGUMENT, a BadRequest violation, and a message leading with the field path), never at sync. The store's listing applies the filter before it counts against max_objects, so an excluded object is never fetched, and the closing listing names only matches: narrowing the filter through UpdateSource tombstones what it now excludes on the next sync. The kit's object-storage connect form, with its shared collection field, moves here from the catalog change and takes the key filter, placing a host refusal under the field it names. Refs #1211 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../api/accounts/authority/contract.binpb | Bin 1199552 -> 1202309 bytes .../proto/saas/accounts/v1/datasource.proto | 73 +++++ .../api/accounts/connect/contract.binpb | Bin 1199552 -> 1202309 bytes .../proto/saas/accounts/v1/datasource.proto | 73 +++++ module/contracts/api/catalog.codefly.json | 4 +- module/module.package.codefly.yaml | 4 +- module/services/accounts/AGENTS.md | 32 ++ .../code/pkg/adapters/datasource_handler.go | 14 + .../code/pkg/business/audit_registry.go | 5 +- .../pkg/business/datasource_connectors.go | 2 +- .../pkg/business/datasource_deliveries.go | 17 +- .../pkg/business/datasource_file_filter.go | 89 ++++++ .../business/datasource_key_filter_test.go | 115 ++++++++ .../code/pkg/business/datasource_update.go | 104 ++++++- .../datasource_upload_key_filter_test.go | 205 +++++++++++++ .../accounts/code/pkg/business/datasources.go | 38 ++- .../code/pkg/business/datasources_test.go | 22 +- .../code/pkg/datasource/objectstore/client.go | 23 +- .../pkg/datasource/objectstore/client_test.go | 15 +- .../pkg/datasource/objectstore/guard_test.go | 2 +- .../pkg/gen/saas/accounts/v1/datasource.pb.go | 113 +++++-- .../proto/saas/accounts/v1/datasource.proto | 73 +++++ .../__tests__/no-raw-type-primitives.test.ts | 2 +- .../generated/contract/catalog.codefly.json | 2 +- .../generated/contract/contract.binpb | Bin 1199552 -> 1202309 bytes .../saas-sdk/generated/library.codefly.yaml | 2 +- .../src/gen/saas/accounts/v1/datasource_pb.ts | 56 +++- .../frontend/code/packages/saas-ui/README.md | 14 +- .../connect-object-storage-form.test.tsx | 103 +++++++ .../datasources/__tests__/gateway.test.tsx | 14 + .../src/datasources/collection-field.tsx | 112 +++++++ .../src/datasources/connect-github-form.tsx | 95 ++---- .../connect-object-storage-form.tsx | 278 ++++++++++++++++++ .../saas-ui/src/datasources/errors.ts | 21 ++ .../saas-ui/src/datasources/gateway.ts | 25 ++ .../saas-ui/src/datasources/queries.ts | 16 + .../saas-ui/src/datasources/schema.ts | 43 ++- .../packages/saas-ui/src/datasources/types.ts | 27 ++ .../packages/saas-ui/src/datasources/util.ts | 12 + .../code/packages/saas-ui/src/index.ts | 8 + .../saas-ui/stories/datasources.stories.tsx | 21 ++ .../src/gen/saas/accounts/v1/datasource_pb.ts | 56 +++- .../services/frontend/code/ui-inventory.json | 51 ++++ 43 files changed, 1845 insertions(+), 136 deletions(-) create mode 100644 module/services/accounts/code/pkg/business/datasource_key_filter_test.go create mode 100644 module/services/accounts/code/pkg/business/datasource_upload_key_filter_test.go create mode 100644 module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/connect-object-storage-form.test.tsx create mode 100644 module/services/frontend/code/packages/saas-ui/src/datasources/collection-field.tsx create mode 100644 module/services/frontend/code/packages/saas-ui/src/datasources/connect-object-storage-form.tsx diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index 590c0a8eb1d2d00c9520361083a0d0e6c244e78f..db3861fdbaf6b58c3a0e037eba6febbf3fd3512f 100644 GIT binary patch delta 42298 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 |F3G)MHv6KavNl>$n-tk$2`NgG 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 |F3G)MHv6KavNl>$n-tk$2`NgG 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 maxKeyPatterns { + return nil, keyFilterRefused(field, fmt.Sprintf("at most %d patterns", maxKeyPatterns)) + } + var out []string + seen := map[string]bool{} + for i, raw := range values { + value := strings.TrimSpace(raw) + path := fmt.Sprintf("%s[%d]", field, i) + switch { + case value == "": + return nil, keyFilterRefused(path, "a pattern cannot be empty") + case len(value) > maxKeyPatternLength: + return nil, keyFilterRefused(path, fmt.Sprintf("a pattern is at most %d characters", maxKeyPatternLength)) + } + if _, err := regexp.Compile(value); err != nil { + return nil, keyFilterRefused(path, fmt.Sprintf("%q is not a valid RE2 expression: %v", value, err)) + } + if !seen[value] { + out = append(out, value) + seen[value] = true + } + } + return out, nil +} + +// uploadKeyScope is an object-storage source's key filter: a key is in scope +// when, with the prefix removed, it matches an include pattern (or there are +// none), matches no exclude pattern, and carries an admitted file type. The +// patterns were compiled when the config was accepted; one that no longer +// compiles admits nothing rather than everything. +func uploadKeyScope(cfg *UploadDatasourceConfig) func(key string) bool { + compile := func(patterns []string) ([]*regexp.Regexp, bool) { + out := make([]*regexp.Regexp, 0, len(patterns)) + for _, p := range patterns { + re, err := regexp.Compile(p) + if err != nil { + return nil, false + } + out = append(out, re) + } + return out, true + } + include, okInclude := compile(cfg.IncludePatterns) + exclude, okExclude := compile(cfg.ExcludePatterns) + if !okInclude || !okExclude { + return func(string) bool { return false } + } + matchesAny := func(res []*regexp.Regexp, s string) bool { + for _, re := range res { + if re.MatchString(s) { + return true + } + } + return false + } + return func(key string) bool { + relative := strings.TrimPrefix(key, cfg.Prefix) + if len(include) > 0 && !matchesAny(include, relative) { + return false + } + return !matchesAny(exclude, relative) && fileTypeAllowed(relative, cfg.FileExtensions) + } +} diff --git a/module/services/accounts/code/pkg/business/datasource_key_filter_test.go b/module/services/accounts/code/pkg/business/datasource_key_filter_test.go new file mode 100644 index 000000000..6c23928e7 --- /dev/null +++ b/module/services/accounts/code/pkg/business/datasource_key_filter_test.go @@ -0,0 +1,115 @@ +package business + +import ( + "strings" + "testing" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// TestUploadKeyScope holds an object-storage source's key filter to its +// contract: patterns match the key with the prefix removed, unanchored and +// case-sensitive; an empty include admits everything; exclude wins; and file +// types intersect, case-insensitively, as on GitHub. +func TestUploadKeyScope(t *testing.T) { + cases := []struct { + name string + cfg UploadDatasourceConfig + admit []string + exclude []string + }{ + { + name: "no filter admits every key under the prefix", + cfg: UploadDatasourceConfig{Prefix: "kb/"}, + admit: []string{"kb/a.md", "kb/deep/b.pdf"}, + }, + { + name: "patterns see the key relative to the prefix", + cfg: UploadDatasourceConfig{Prefix: "kb/", IncludePatterns: []string{`^guides/`}}, + admit: []string{"kb/guides/a.md"}, + exclude: []string{"kb/notes/guides/a.md", "kb/a.md"}, + }, + { + name: "exclude wins over include", + cfg: UploadDatasourceConfig{Prefix: "kb/", IncludePatterns: []string{`\.md$`}, ExcludePatterns: []string{`^drafts/`}}, + admit: []string{"kb/guides/a.md"}, + exclude: []string{"kb/drafts/a.md", "kb/guides/a.pdf"}, + }, + { + name: "patterns are case-sensitive unless they fold case", + cfg: UploadDatasourceConfig{Prefix: "kb/", IncludePatterns: []string{`^Policies/`, `(?i)^handbook/`}}, + admit: []string{"kb/Policies/a.md", "kb/HANDBOOK/a.md"}, + exclude: []string{"kb/policies/a.md"}, + }, + { + name: "file types intersect with the patterns, case-insensitively", + cfg: UploadDatasourceConfig{Prefix: "kb/", IncludePatterns: []string{`^guides/`}, FileExtensions: []string{".md"}}, + admit: []string{"kb/guides/A.MD"}, + exclude: []string{"kb/guides/a.pdf", "kb/other/a.md"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + inScope := uploadKeyScope(&tc.cfg) + for _, key := range tc.admit { + if !inScope(key) { + t.Errorf("%q is excluded, want admitted", key) + } + } + for _, key := range tc.exclude { + if inScope(key) { + t.Errorf("%q is admitted, want excluded", key) + } + } + }) + } +} + +// TestNormalizeKeyPatternsRefusesByName: every refusal is INVALID_ARGUMENT and +// names the offending element, in the message a client reads and in the +// BadRequest violation a client can decode. +func TestNormalizeKeyPatternsRefusesByName(t *testing.T) { + tooMany := make([]string, maxKeyPatterns+1) + for i := range tooMany { + tooMany[i] = "a" + } + cases := []struct { + name string + patterns []string + field string + mention string + }{ + {"an invalid RE2 expression", []string{`\.md$`, `(unclosed`}, "upload.include_patterns[1]", `"(unclosed"`}, + {"a backreference RE2 does not support", []string{`(a)\1`}, "upload.include_patterns[0]", `"(a)\\1"`}, + {"an empty pattern", []string{" "}, "upload.include_patterns[0]", "empty"}, + {"a pattern over the length cap", []string{strings.Repeat("a", maxKeyPatternLength+1)}, "upload.include_patterns[0]", "256"}, + {"more patterns than the cap", tooMany, "upload.include_patterns", "16"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := normalizeKeyPatterns("upload.include_patterns", tc.patterns) + st := status.Convert(err) + if err == nil || st.Code() != codes.InvalidArgument { + t.Fatalf("err = %v, want INVALID_ARGUMENT", err) + } + if !strings.HasPrefix(st.Message(), tc.field+": ") || !strings.Contains(st.Message(), tc.mention) { + t.Fatalf("message %q does not name %s and %s", st.Message(), tc.field, tc.mention) + } + var field string + for _, d := range st.Details() { + if br, ok := d.(*errdetails.BadRequest); ok && len(br.GetFieldViolations()) == 1 { + field = br.GetFieldViolations()[0].GetField() + } + } + if field != tc.field { + t.Fatalf("BadRequest field = %q, want %q", field, tc.field) + } + }) + } + got, err := normalizeKeyPatterns("upload.exclude_patterns", []string{" ^drafts/ ", "^drafts/", `\.tmp$`}) + if err != nil || strings.Join(got, ",") != `^drafts/,\.tmp$` { + t.Fatalf("normalize = %v, %v; want trimmed and de-duplicated", got, err) + } +} diff --git a/module/services/accounts/code/pkg/business/datasource_update.go b/module/services/accounts/code/pkg/business/datasource_update.go index 05675e6c2..4b81c28cb 100644 --- a/module/services/accounts/code/pkg/business/datasource_update.go +++ b/module/services/accounts/code/pkg/business/datasource_update.go @@ -25,6 +25,9 @@ type DatasourceFetchScope struct { Branch string Paths []string FileExtensions []string + // An object-storage source's key patterns; FileExtensions is its file types. + IncludePatterns []string + ExcludePatterns []string } // UpdateSourceInput is what a connected source may be re-aimed at: its branch @@ -36,6 +39,10 @@ type UpdateSourceInput struct { Branch string Paths []string FileExtensions []string + // IncludePatterns and ExcludePatterns are an object-storage source's key + // patterns, replaced whole like the rest. + IncludePatterns []string + ExcludePatterns []string // Expected is the scope the caller was shown, which the edit is accepted // against. Required, and nil is refused rather than read as "apply it // anyway": a whole-field replacement applied unconditionally is precisely @@ -150,8 +157,14 @@ func (s *Service) UpdateSource(ctx context.Context, actorID string, input Update // providers are configured by their own fields (a base URL, a sitemap, a // bucket prefix), and silently accepting a branch for one of them would // report a change that changed nothing. + if source.Provider == DatasourceProviderUpload { + return s.updateUploadKeyFilter(ctx, actorID, source, input) + } if source.Provider != DatasourceProviderGitHub { - return nil, "", status.Error(codes.FailedPrecondition, "only a github source has a branch and path filters") + return nil, "", status.Error(codes.FailedPrecondition, "only a github or object-storage source has filters to edit") + } + if len(input.IncludePatterns) > 0 || len(input.ExcludePatterns) > 0 { + return nil, "", keyFilterRefused("include_patterns", "a github source has no key patterns") } branch := strings.TrimSpace(input.Branch) @@ -251,6 +264,95 @@ func (s *Service) UpdateSource(ctx context.Context, actorID string, input Update return &updated, jobID, nil } +// sameUploadKeyFilter reports whether scope is the key filter cfg holds, after +// the same normalization comparison sameFetchScope applies. +func sameUploadKeyFilter(scope DatasourceFetchScope, cfg *UploadDatasourceConfig) bool { + trimmed := func(values []string) []string { + var out []string + for _, v := range values { + if v = strings.TrimSpace(v); v != "" && !slices.Contains(out, v) { + out = append(out, v) + } + } + return out + } + return slices.Equal(trimmed(scope.IncludePatterns), trimmed(cfg.IncludePatterns)) && + slices.Equal(trimmed(scope.ExcludePatterns), trimmed(cfg.ExcludePatterns)) && + slices.Equal(comparableFileExtensions(scope.FileExtensions), comparableFileExtensions(cfg.FileExtensions)) +} + +// updateUploadKeyFilter is UpdateSource for an object-storage source: it +// replaces the key patterns and file types and nothing else (the bucket and +// prefix are the source's identity), under the same lock, expectation, audit +// and reconfigured sync as a github edit. The next sync lists through the new +// filter, so a narrowed filter's closing listing omits — and the store +// tombstones — every key it now excludes. +func (s *Service) updateUploadKeyFilter(ctx context.Context, actorID string, source *DatasourceSource, input UpdateSourceInput) (*DatasourceSource, string, error) { + w := wool.Get(ctx).In("updateUploadKeyFilter") + if strings.TrimSpace(input.Branch) != "" || len(input.Paths) > 0 { + return nil, "", keyFilterRefused("paths", "an object-storage source has no branch or paths; it is scoped by its prefix and key patterns") + } + var next UploadDatasourceConfig + if err := normalizeUploadKeyFilter(&next, &UploadDatasourceConfig{ + IncludePatterns: input.IncludePatterns, ExcludePatterns: input.ExcludePatterns, FileExtensions: input.FileExtensions, + }, ""); err != nil { + return nil, "", err + } + nextScope := DatasourceFetchScope{IncludePatterns: next.IncludePatterns, ExcludePatterns: next.ExcludePatterns, FileExtensions: next.FileExtensions} + var ( + updated DatasourceSource + changed bool + ) + if err := s.store.WithOrgTx(ctx, source.OrgID, func(ctx context.Context) error { + current, err := s.store.LockDatasourceSourceFetchScope(ctx, source.OrgID, source.ID) + if err != nil { + return err + } + if current == nil || current.Upload == nil { + return ErrDatasourceSourceNotFound + } + if !sameUploadKeyFilter(*input.Expected, current.Upload) { + return ErrDatasourceSourceScopeChanged + } + updated = *current + updated.BoundaryLabel = source.BoundaryLabel + if sameUploadKeyFilter(nextScope, current.Upload) { + return nil + } + cfg := *current.Upload + cfg.IncludePatterns, cfg.ExcludePatterns, cfg.FileExtensions = next.IncludePatterns, next.ExcludePatterns, next.FileExtensions + updated.Upload = &cfg + if err := s.store.UpdateDatasourceSourceFetchScope(ctx, &updated); err != nil { + return err + } + changed = true + return s.emitTx(ctx, actorID, "user", EventDatasourceSourceUpdated, "datasource", updated.ID, updated.OrgID, map[string]any{ + "bucket": cfg.Bucket, + "prefix": cfg.Prefix, + "include_patterns": cfg.IncludePatterns, + "exclude_patterns": cfg.ExcludePatterns, + "file_extensions": cfg.FileExtensions, + "boundary": updated.BoundaryNodeID, + }) + }); err != nil { + if errors.Is(err, ErrDatasourceSourceNotFound) || errors.Is(err, ErrDatasourceSourceScopeChanged) { + return nil, "", err + } + return nil, "", w.Wrapf(err, "persist datasource source update") + } + if !changed { + return &updated, "", nil + } + // A pull source re-syncs through its own engine, not GitHub's reconcile. + jobID, err := s.enqueuePullSync(ctx, &updated, datasourceSyncRequestSource, NewIDString()) + if err != nil { + w.Warn("enqueue re-sync failed; the periodic reconcile will re-sync the source", + wool.Field("source", updated.ID), wool.ErrField(err)) + return &updated, "", nil + } + return &updated, jobID, nil +} + // enqueueReconfiguredSync queues the re-snapshot an update owes the consuming // module: the same forced reconcile "Sync now" and the first sync at connect // enqueue, in the mode that names why it ran. diff --git a/module/services/accounts/code/pkg/business/datasource_upload_key_filter_test.go b/module/services/accounts/code/pkg/business/datasource_upload_key_filter_test.go new file mode 100644 index 000000000..35ff9a27e --- /dev/null +++ b/module/services/accounts/code/pkg/business/datasource_upload_key_filter_test.go @@ -0,0 +1,205 @@ +package business_test + +import ( + "context" + "reflect" + "sort" + "strings" + "testing" + + "accounts/pkg/business" + "accounts/pkg/datasource/objectstore" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// refusedField is the field an INVALID_ARGUMENT refusal names, read from its +// BadRequest violation, after checking the message leads with the same path. +func refusedField(t *testing.T, err error) string { + t.Helper() + st := status.Convert(err) + if err == nil || st.Code() != codes.InvalidArgument { + t.Fatalf("err = %v, want INVALID_ARGUMENT", err) + } + for _, d := range st.Details() { + if br, ok := d.(*errdetails.BadRequest); ok && len(br.GetFieldViolations()) == 1 { + field := br.GetFieldViolations()[0].GetField() + if !strings.HasPrefix(st.Message(), field+": ") { + t.Fatalf("message %q does not lead with the field %q", st.Message(), field) + } + return field + } + } + t.Fatalf("refusal %v carries no BadRequest violation", err) + return "" +} + +// An invalid key filter is refused when the source is added — by name, and +// before anything is stored — never discovered at sync. +func TestAddSource_UploadRefusesAnInvalidKeyFilterByField(t *testing.T) { + cases := map[string]struct { + mutate func(*business.UploadDatasourceConfig) + field string + }{ + "invalid include": {func(c *business.UploadDatasourceConfig) { c.IncludePatterns = []string{`ok`, `[z-a]`} }, "upload.include_patterns[1]"}, + "invalid exclude": {func(c *business.UploadDatasourceConfig) { c.ExcludePatterns = []string{`^(?!drafts/)`} }, "upload.exclude_patterns[0]"}, + "invalid file type": {func(c *business.UploadDatasourceConfig) { c.FileExtensions = []string{"*.md"} }, "upload.file_extensions"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + store := newDatasourceFakeStore() + svc, _ := newDatasourceService(store, &recordingProducer{}, nil) + cfg := uploadConfig() + tc.mutate(cfg) + _, err := svc.AddExistingSource(context.Background(), "actor-1", business.AddSourceInput{ + OrgID: testOrg, Provider: business.DatasourceProviderUpload, CollectionLabel: "guides", + Credential: "secretkey", Upload: cfg, + }) + if got := refusedField(t, err); got != tc.field { + t.Fatalf("refused field = %q, want %q", got, tc.field) + } + if sources, _ := svc.ListDatasourceSources(context.Background(), testOrg); len(sources) != 0 { + t.Fatalf("a refused source was stored: %+v", sources) + } + }) + } +} + +// filteredUploadSource connects an object-storage source with a key filter. +func filteredUploadSource(t *testing.T, svc *business.Service, include, exclude []string) *business.DatasourceSource { + t.Helper() + cfg := uploadConfig() + cfg.IncludePatterns, cfg.ExcludePatterns = include, exclude + source, err := svc.AddExistingSource(context.Background(), "actor-1", business.AddSourceInput{ + OrgID: testOrg, Provider: business.DatasourceProviderUpload, CollectionLabel: "guides", + Credential: "secretkey", Upload: cfg, + }) + if err != nil { + t.Fatal(err) + } + return source +} + +func uploadScopeOf(source *business.DatasourceSource) *business.DatasourceFetchScope { + return &business.DatasourceFetchScope{ + IncludePatterns: source.Upload.IncludePatterns, + ExcludePatterns: source.Upload.ExcludePatterns, + FileExtensions: source.Upload.FileExtensions, + } +} + +func TestUpdateSource_UploadRefusesAnInvalidKeyFilterByField(t *testing.T) { + store := newDatasourceFakeStore() + svc, audit := newDatasourceService(store, &recordingProducer{}, nil) + source := filteredUploadSource(t, svc, []string{`^guides/`}, nil) + + _, _, err := svc.UpdateSource(context.Background(), "actor-1", business.UpdateSourceInput{ + OrgID: testOrg, ID: source.ID, IncludePatterns: []string{`^guides/`}, ExcludePatterns: []string{`a{1001}`}, + Expected: uploadScopeOf(source), + }) + if got := refusedField(t, err); got != "exclude_patterns[0]" { + t.Fatalf("refused field = %q, want exclude_patterns[0]", got) + } + _, _, err = svc.UpdateSource(context.Background(), "actor-1", business.UpdateSourceInput{ + OrgID: testOrg, ID: source.ID, Paths: []string{"docs/"}, Expected: uploadScopeOf(source), + }) + if got := refusedField(t, err); got != "paths" { + t.Fatalf("refused field = %q, want paths: a bucket source has no paths", got) + } + stored, err := svc.GetDatasourceSource(context.Background(), testOrg, source.ID) + if err != nil || !reflect.DeepEqual(stored.Upload.ExcludePatterns, []string(nil)) { + t.Fatalf("a refused edit was written: %+v, %v", stored.Upload, err) + } + if entries := audit.entriesOf(business.EventDatasourceSourceUpdated); len(entries) != 0 { + t.Fatalf("updated events = %d, want 0 for a refused edit", len(entries)) + } +} + +func listedKeys(t *testing.T, l mirrorListing) []string { + t.Helper() + var keys []string + for _, f := range *l.Files { + keys = append(keys, f.Key) + } + sort.Strings(keys) + return keys +} + +func objectKeys(p *recordingProducer) []string { + var keys []string + for _, job := range uploadObjectJobs(p) { + keys = append(keys, job.GetAttributes()["upload.key"]) + } + sort.Strings(keys) + return keys +} + +// The mixed listing on the mirror seam: only matching keys are enqueued, an +// excluded key is never fetched, and the closing listing names only matches. +// Narrowing the filter through UpdateSource then re-syncs, and that sync's +// closing listing drops exactly the keys the narrower filter excludes — which +// is what tombstones them in the store — and nothing else. +func TestRunDatasourceSync_UploadKeyFilterNarrowingTombstonesOnlyTheExcluded(t *testing.T) { + producer := &recordingProducer{} + svc, audit := newDatasourceService(newDatasourceFakeStore(), producer, nil) + fake := &fakeUploadClient{ + entries: []objectstore.Entry{ + {Key: "kb/guides/a.md", ETag: "a"}, {Key: "kb/guides/b.pdf", ETag: "b"}, + {Key: "kb/drafts/c.md", ETag: "c"}, {Key: "kb/notes/d.md", ETag: "d"}, + }, + objects: map[string]objectstore.Object{}, + } + for _, e := range fake.entries { + fake.objects[e.Key] = objectstore.Object{Key: e.Key, Body: []byte(e.Key), ETag: e.ETag} + } + svc.SetDatasourceUploadClientFactory(func(business.UploadDatasourceConfig, string) business.UploadContentClient { return fake }) + source := filteredUploadSource(t, svc, []string{`\.md$`, `^guides/`}, []string{`^drafts/`}) + takeConnectSync(t, producer, source) + + if _, err := svc.RunDatasourceSync(context.Background(), source.ID); err != nil { + t.Fatal(err) + } + wide := []string{"kb/guides/a.md", "kb/guides/b.pdf", "kb/notes/d.md"} + if got := objectKeys(producer); !reflect.DeepEqual(got, wide) { + t.Fatalf("enqueued = %v, want %v", got, wide) + } + sort.Strings(fake.fetched) + if !reflect.DeepEqual(fake.fetched, wide) { + t.Fatalf("fetched = %v, want only the matches %v: an excluded key is never read", fake.fetched, wide) + } + if listings := uploadListings(t, producer); len(listings) != 1 || !reflect.DeepEqual(listedKeys(t, listings[0]), wide) { + t.Fatalf("closing listing does not name exactly the matches %v", wide) + } + + // Narrow: exclude the notes folder as well. + updated, jobID, err := svc.UpdateSource(context.Background(), "actor-1", business.UpdateSourceInput{ + OrgID: testOrg, ID: source.ID, + IncludePatterns: []string{`\.md$`, `^guides/`}, ExcludePatterns: []string{`^drafts/`, `^notes/`}, + Expected: uploadScopeOf(source), + }) + if err != nil || jobID == "" { + t.Fatalf("narrowing = %v, job %q; want a re-sync", err, jobID) + } + if entries := audit.entriesOf(business.EventDatasourceSourceUpdated); len(entries) != 1 { + t.Fatalf("updated events = %d, want 1", len(entries)) + } + if _, _, err := svc.UpdateSource(context.Background(), "actor-1", business.UpdateSourceInput{ + OrgID: testOrg, ID: source.ID, IncludePatterns: []string{`.*`}, Expected: uploadScopeOf(source), + }); err != business.ErrDatasourceSourceScopeChanged { + t.Fatalf("an edit against the scope before the narrowing = %v, want ErrDatasourceSourceScopeChanged", err) + } + + producer.mu.Lock() + producer.jobs = nil + producer.mu.Unlock() + if _, err := svc.RunDatasourceSync(context.Background(), updated.ID); err != nil { + t.Fatal(err) + } + narrow := []string{"kb/guides/a.md", "kb/guides/b.pdf"} + listings := uploadListings(t, producer) + if len(listings) != 1 || !reflect.DeepEqual(listedKeys(t, listings[0]), narrow) { + t.Fatalf("closing listing after narrowing does not name exactly %v: kb/notes/d.md must be the only key dropped", narrow) + } +} diff --git a/module/services/accounts/code/pkg/business/datasources.go b/module/services/accounts/code/pkg/business/datasources.go index fc692103e..140b8fbe6 100644 --- a/module/services/accounts/code/pkg/business/datasources.go +++ b/module/services/accounts/code/pkg/business/datasources.go @@ -365,6 +365,11 @@ type UploadDatasourceConfig struct { Prefix string `json:"prefix,omitempty"` AccessKeyID string `json:"access_key_id"` MaxObjects int `json:"max_objects,omitempty"` + // Which keys under the prefix are ingested: RE2 patterns over the key with + // the prefix removed, and a file-type allowlist (uploadKeyScope). + IncludePatterns []string `json:"include_patterns,omitempty"` + ExcludePatterns []string `json:"exclude_patterns,omitempty"` + FileExtensions []string `json:"file_extensions,omitempty"` } // DatasourceSource is one connected external datasource. CredentialSecretRef and @@ -513,7 +518,9 @@ type CrawlerContentClient interface { // needs. The Service builds one per Source from its config and decrypted secret // access key and drives it item-at-a-time (List then Fetch). type UploadContentClient interface { - List(ctx context.Context) (objectstore.Listing, error) + // List lists the keys match admits; a key it refuses is neither returned + // nor counted against the object cap. + List(ctx context.Context, match func(key string) bool) (objectstore.Listing, error) Fetch(ctx context.Context, key string) (objectstore.Object, error) } @@ -934,6 +941,10 @@ func (s *Service) addSource(ctx context.Context, actorID string, input AddSource } cfg, err := normalizeUploadConfig(input.Upload) if err != nil { + // A key-filter refusal names its field; it reaches the caller as itself. + if status.Code(err) == codes.InvalidArgument { + return nil, err + } return nil, w.Wrap(err) } source.Upload = cfg @@ -1117,6 +1128,22 @@ func normalizeCrawlerConfig(cfg *CrawlerDatasourceConfig) (*CrawlerDatasourceCon return &CrawlerDatasourceConfig{SitemapURL: sitemapURL, MaxPages: maxPages}, nil } +// normalizeUploadKeyFilter validates from's key filter into to, naming each +// field under path (its place in the request) when it refuses one. +func normalizeUploadKeyFilter(to, from *UploadDatasourceConfig, path string) error { + var err error + if to.IncludePatterns, err = normalizeKeyPatterns(path+"include_patterns", from.IncludePatterns); err != nil { + return err + } + if to.ExcludePatterns, err = normalizeKeyPatterns(path+"exclude_patterns", from.ExcludePatterns); err != nil { + return err + } + if to.FileExtensions, err = normalizeFileExtensions(from.FileExtensions); err != nil { + return keyFilterRefused(path+"file_extensions", status.Convert(err).Message()) + } + return nil +} + // normalizeUploadConfig validates and trims an object-storage provider config. func normalizeUploadConfig(cfg *UploadDatasourceConfig) (*UploadDatasourceConfig, error) { if cfg == nil { @@ -1147,6 +1174,9 @@ func normalizeUploadConfig(cfg *UploadDatasourceConfig) (*UploadDatasourceConfig if out.MaxObjects < 0 { return nil, errors.New("upload max objects must not be negative") } + if err := normalizeUploadKeyFilter(out, cfg, "upload."); err != nil { + return nil, err + } return out, nil } @@ -1917,7 +1947,11 @@ func (s *Service) runUploadSync(ctx context.Context, source *DatasourceSource) ( } client := s.newUploadClient(*source.Upload, secretKey) - listing, err := client.List(ctx) + // The source's key filter is applied as the prefix is listed, so an + // excluded object is never fetched, and the closing listing — built from + // what was delivered — names only matches: narrowing the filter tombstones + // what it now excludes on the next sync. + listing, err := client.List(ctx, uploadKeyScope(source.Upload)) if err != nil { return 0, w.Wrapf(err, "list objects") } diff --git a/module/services/accounts/code/pkg/business/datasources_test.go b/module/services/accounts/code/pkg/business/datasources_test.go index 9be154ded..90f8f3534 100644 --- a/module/services/accounts/code/pkg/business/datasources_test.go +++ b/module/services/accounts/code/pkg/business/datasources_test.go @@ -143,7 +143,7 @@ func (f *datasourceFakeStore) InsertDatasourceSource(_ context.Context, source * } // UpdateDatasourceSourceFetchScope mirrors the store's narrow UPDATE: only the -// three fetch-scope fields are written, so a business change that tried to move +// fetch-scope fields and the config envelope are written, so a business change that tried to move // a source's repository or credential through this call fails here as it would // against the statement. // @@ -162,6 +162,12 @@ func (f *datasourceFakeStore) UpdateDatasourceSourceFetchScope(_ context.Context stored.Branch = source.Branch stored.Paths = source.Paths stored.FileExtensions = source.FileExtensions + // The statement also rewrites the config envelope, which for an + // object-storage source is its whole config, key filter included. + if source.Upload != nil { + cfg := *source.Upload + stored.Upload = &cfg + } return nil } @@ -1238,6 +1244,7 @@ type fakeCrawlerClient struct { listErr error listCalls int fetchCalls int + fetched []string } func (f *fakeCrawlerClient) List(context.Context) ([]string, error) { @@ -1276,18 +1283,27 @@ type fakeUploadClient struct { incomplete bool listCalls int fetchCalls int + fetched []string } -func (f *fakeUploadClient) List(context.Context) (objectstore.Listing, error) { +// List applies match as the real client does, before anything is fetched. +func (f *fakeUploadClient) List(_ context.Context, match func(string) bool) (objectstore.Listing, error) { f.listCalls++ if f.listErr != nil { return objectstore.Listing{}, f.listErr } - return objectstore.Listing{Entries: f.entries, Complete: !f.incomplete}, nil + var entries []objectstore.Entry + for _, e := range f.entries { + if match == nil || match(e.Key) { + entries = append(entries, e) + } + } + return objectstore.Listing{Entries: entries, Complete: !f.incomplete}, nil } func (f *fakeUploadClient) Fetch(_ context.Context, key string) (objectstore.Object, error) { f.fetchCalls++ + f.fetched = append(f.fetched, key) if err := f.fetchErr[key]; err != nil { return objectstore.Object{}, err } diff --git a/module/services/accounts/code/pkg/datasource/objectstore/client.go b/module/services/accounts/code/pkg/datasource/objectstore/client.go index 8d85fbba4..2c88f2f8d 100644 --- a/module/services/accounts/code/pkg/datasource/objectstore/client.go +++ b/module/services/accounts/code/pkg/datasource/objectstore/client.go @@ -167,11 +167,13 @@ type Listing struct { Complete bool } -// List issues one ListObjectsV2 request and returns its entries, bounded by the -// configured object cap. Listing failure (including a non-2xx list) is a returned -// error so a credential or permission problem surfaces rather than reading as an -// empty bucket. -func (c *Client) List(ctx context.Context) (Listing, error) { +// List issues one ListObjectsV2 request and returns the entries match admits, +// bounded by the configured object cap: an entry match refuses is dropped +// before it is counted, so it never uses up the cap. A nil match admits every +// entry. Listing failure (including a non-2xx list) is a returned error so a +// credential or permission problem surfaces rather than reading as an empty +// bucket. +func (c *Client) List(ctx context.Context, match func(key string) bool) (Listing, error) { if c.baseErr != nil { return Listing{}, c.baseErr } @@ -201,13 +203,16 @@ func (c *Client) List(ctx context.Context) (Listing, error) { limit = maxObjectsCeiling } entries := make([]Entry, 0, len(parsed.Contents)) + matched := 0 for _, e := range parsed.Contents { - if len(entries) >= limit { - break + if match != nil && !match(e.Key) { + continue + } + if matched++; matched <= limit { + entries = append(entries, Entry{Key: e.Key, ETag: e.ETag}) } - entries = append(entries, Entry{Key: e.Key, ETag: e.ETag}) } - return Listing{Entries: entries, Complete: !parsed.IsTruncated && len(parsed.Contents) <= limit}, nil + return Listing{Entries: entries, Complete: !parsed.IsTruncated && matched <= limit}, nil } // listEntry is one row of a ListBucketResult. diff --git a/module/services/accounts/code/pkg/datasource/objectstore/client_test.go b/module/services/accounts/code/pkg/datasource/objectstore/client_test.go index f0bac7031..76af89a1a 100644 --- a/module/services/accounts/code/pkg/datasource/objectstore/client_test.go +++ b/module/services/accounts/code/pkg/datasource/objectstore/client_test.go @@ -72,7 +72,7 @@ func TestFetchHappyPath(t *testing.T) { AccessKeyID: "AKIAIOSFODNN7EXAMPLE", }, "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY") - listing, err := c.List(context.Background()) + listing, err := c.List(context.Background(), nil) if err != nil { t.Fatalf("List: %v", err) } @@ -132,7 +132,7 @@ func TestAuthorizationHeaderStructure(t *testing.T) { Bucket: "b", AccessKeyID: "AKIAIOSFODNN7EXAMPLE", }, "secret") - if _, err := c.List(context.Background()); err != nil { + if _, err := c.List(context.Background(), nil); err != nil { t.Fatalf("List: %v", err) } @@ -293,7 +293,7 @@ func TestSSRFGuardBlocksLoopback(t *testing.T) { Region: "us-east-1", AccessKeyID: "AKIAEXAMPLE", }, "secret") - if _, err := c.List(context.Background()); err == nil { + if _, err := c.List(context.Background(), nil); err == nil { t.Fatal("expected an error listing a loopback endpoint, got nil") } } @@ -327,10 +327,17 @@ func TestListReportsATruncatedListingIncomplete(t *testing.T) { {"whole listing within the cap", "false", 3, 3, true}, {"store truncated the page", "true", 10, 3, false}, {"object cap cut the page", "false", 2, 2, false}, + // An excluded key is dropped before it is counted: two matches fit a + // cap of two, and the listing is still whole. + {"excluded keys never use up the cap", "false", 2, 2, true}, } { t.Run(tc.name, func(t *testing.T) { truncated = tc.truncated - listing, err := client(tc.max).List(context.Background()) + var match func(string) bool + if strings.HasPrefix(tc.name, "excluded") { + match = func(key string) bool { return key != "docs/b.txt" } + } + listing, err := client(tc.max).List(context.Background(), match) if err != nil { t.Fatal(err) } diff --git a/module/services/accounts/code/pkg/datasource/objectstore/guard_test.go b/module/services/accounts/code/pkg/datasource/objectstore/guard_test.go index f7b7be09a..06be14ac5 100644 --- a/module/services/accounts/code/pkg/datasource/objectstore/guard_test.go +++ b/module/services/accounts/code/pkg/datasource/objectstore/guard_test.go @@ -67,7 +67,7 @@ func TestEveryOutboundDialIsGuarded(t *testing.T) { call func(ctx context.Context) error }{ {"listing dial (List)", func(ctx context.Context) error { - _, err := newGuardedClient(origin).List(ctx) + _, err := newGuardedClient(origin).List(ctx, nil) return err }}, {"object dial (Fetch)", func(ctx context.Context) error { diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/datasource.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/datasource.pb.go index c223254c6..3ba78e16a 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/datasource.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/datasource.pb.go @@ -1249,9 +1249,25 @@ type UploadDatasourceConfig struct { // AWS-style access key id. The matching secret access key is the credential. AccessKeyId string `protobuf:"bytes,5,opt,name=access_key_id,json=accessKeyId,proto3" json:"access_key_id,omitempty"` // Upper bound on objects fetched per sync. Zero applies the connector default. - MaxObjects uint32 `protobuf:"varint,6,opt,name=max_objects,json=maxObjects,proto3" json:"max_objects,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Only objects that pass the filters below count against it. + MaxObjects uint32 `protobuf:"varint,6,opt,name=max_objects,json=maxObjects,proto3" json:"max_objects,omitempty"` + // Which keys under the prefix are ingested. Each pattern is an RE2 regular + // expression matched against the object key with the prefix removed, + // unanchored and case-sensitive (anchor with ^ and $, fold case with (?i)). + // An empty include admits every key; an exclude match always wins. Applied + // when the prefix is listed, before anything is fetched, so an excluded + // object is never read and never counts against max_objects. An invalid + // expression is refused when the source is added or updated, never at sync: + // the call fails INVALID_ARGUMENT with a google.rpc.BadRequest violation, and + // a message that starts with the field's path, e.g. + // "upload.include_patterns[1]: …". + IncludePatterns []string `protobuf:"bytes,7,rep,name=include_patterns,json=includePatterns,proto3" json:"include_patterns,omitempty"` + ExcludePatterns []string `protobuf:"bytes,8,rep,name=exclude_patterns,json=excludePatterns,proto3" json:"exclude_patterns,omitempty"` + // Case-insensitive key suffixes, e.g. [".md", ".pdf"], intersected with the + // patterns. Empty admits every type. GitHub's file_extensions semantics. + FileExtensions []string `protobuf:"bytes,9,rep,name=file_extensions,json=fileExtensions,proto3" json:"file_extensions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UploadDatasourceConfig) Reset() { @@ -1326,6 +1342,27 @@ func (x *UploadDatasourceConfig) GetMaxObjects() uint32 { return 0 } +func (x *UploadDatasourceConfig) GetIncludePatterns() []string { + if x != nil { + return x.IncludePatterns + } + return nil +} + +func (x *UploadDatasourceConfig) GetExcludePatterns() []string { + if x != nil { + return x.ExcludePatterns + } + return nil +} + +func (x *UploadDatasourceConfig) GetFileExtensions() []string { + if x != nil { + return x.FileExtensions + } + return nil +} + // Datasource is one connected external datasource. It is org-scoped and carries // only non-secret projection fields; credential material is deliberately absent. // Exactly one provider config (github, api, crawler, or upload) is set, matching @@ -2084,9 +2121,14 @@ type UpdateSourceRequest struct { // on every successful sync, which would refuse a perfectly good edit for the // sole reason that the source had reconciled since the form was opened. What // an editor needs guarded is what they were looking at. - Expected *SourceFetchScope `protobuf:"bytes,6,opt,name=expected,proto3" json:"expected,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Expected *SourceFetchScope `protobuf:"bytes,6,opt,name=expected,proto3" json:"expected,omitempty"` + // An object-storage source's key patterns (see UploadDatasourceConfig), + // replaced whole like every field here. Its file_extensions is field 5; its + // branch and paths must be empty. Refused for a github source. + IncludePatterns []string `protobuf:"bytes,7,rep,name=include_patterns,json=includePatterns,proto3" json:"include_patterns,omitempty"` + ExcludePatterns []string `protobuf:"bytes,8,rep,name=exclude_patterns,json=excludePatterns,proto3" json:"exclude_patterns,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UpdateSourceRequest) Reset() { @@ -2161,6 +2203,20 @@ func (x *UpdateSourceRequest) GetExpected() *SourceFetchScope { return nil } +func (x *UpdateSourceRequest) GetIncludePatterns() []string { + if x != nil { + return x.IncludePatterns + } + return nil +} + +func (x *UpdateSourceRequest) GetExcludePatterns() []string { + if x != nil { + return x.ExcludePatterns + } + return nil +} + // SourceFetchScope is what a source fetches — the three values UpdateSource // replaces — carried on its own so a request can state the scope it expects to // be replacing as well as the one it wants. @@ -2171,12 +2227,14 @@ func (x *UpdateSourceRequest) GetExpected() *SourceFetchScope { // edit at all. Size is still bounded, because the comparison reads the whole // thing. type SourceFetchScope struct { - state protoimpl.MessageState `protogen:"open.v1"` - Branch string `protobuf:"bytes,1,opt,name=branch,proto3" json:"branch,omitempty"` - Paths []string `protobuf:"bytes,2,rep,name=paths,proto3" json:"paths,omitempty"` - FileExtensions []string `protobuf:"bytes,3,rep,name=file_extensions,json=fileExtensions,proto3" json:"file_extensions,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Branch string `protobuf:"bytes,1,opt,name=branch,proto3" json:"branch,omitempty"` + Paths []string `protobuf:"bytes,2,rep,name=paths,proto3" json:"paths,omitempty"` + FileExtensions []string `protobuf:"bytes,3,rep,name=file_extensions,json=fileExtensions,proto3" json:"file_extensions,omitempty"` + IncludePatterns []string `protobuf:"bytes,4,rep,name=include_patterns,json=includePatterns,proto3" json:"include_patterns,omitempty"` + ExcludePatterns []string `protobuf:"bytes,5,rep,name=exclude_patterns,json=excludePatterns,proto3" json:"exclude_patterns,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SourceFetchScope) Reset() { @@ -2230,6 +2288,20 @@ func (x *SourceFetchScope) GetFileExtensions() []string { return nil } +func (x *SourceFetchScope) GetIncludePatterns() []string { + if x != nil { + return x.IncludePatterns + } + return nil +} + +func (x *SourceFetchScope) GetExcludePatterns() []string { + if x != nil { + return x.ExcludePatterns + } + return nil +} + type UpdateSourceResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Datasource *Datasource `protobuf:"bytes,1,opt,name=datasource,proto3" json:"datasource,omitempty"` @@ -7463,7 +7535,7 @@ const file_saas_accounts_v1_datasource_proto_rawDesc = "" + "\x17CrawlerDatasourceConfig\x12\x1f\n" + "\vsitemap_url\x18\x01 \x01(\tR\n" + "sitemapUrl\x12\x1b\n" + - "\tmax_pages\x18\x02 \x01(\rR\bmaxPages\"\xc1\x01\n" + + "\tmax_pages\x18\x02 \x01(\rR\bmaxPages\"\x98\x03\n" + "\x16UploadDatasourceConfig\x12\x1a\n" + "\bendpoint\x18\x01 \x01(\tR\bendpoint\x12\x16\n" + "\x06region\x18\x02 \x01(\tR\x06region\x12\x16\n" + @@ -7471,7 +7543,10 @@ const file_saas_accounts_v1_datasource_proto_rawDesc = "" + "\x06prefix\x18\x04 \x01(\tR\x06prefix\x12\"\n" + "\raccess_key_id\x18\x05 \x01(\tR\vaccessKeyId\x12\x1f\n" + "\vmax_objects\x18\x06 \x01(\rR\n" + - "maxObjects\"\x83\t\n" + + "maxObjects\x12<\n" + + "\x10include_patterns\x18\a \x03(\tB\x11\xbaH\x0e\x92\x01\v\x10\x10\"\ar\x05\x10\x01\x18\x80\x02R\x0fincludePatterns\x12<\n" + + "\x10exclude_patterns\x18\b \x03(\tB\x11\xbaH\x0e\x92\x01\v\x10\x10\"\ar\x05\x10\x01\x18\x80\x02R\x0fexcludePatterns\x12Y\n" + + "\x0ffile_extensions\x18\t \x03(\tB0\xbaH-\x92\x01*\x10 \"&r$\x10\x02\x18!2\x1e^\\.[A-Za-z0-9][A-Za-z0-9._-]*$R\x0efileExtensions\"\x83\t\n" + "\n" + "Datasource\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x15\n" + @@ -7539,19 +7614,23 @@ const file_saas_accounts_v1_datasource_proto_rawDesc = "" + "\x11AddSourceResponse\x12<\n" + "\n" + "datasource\x18\x01 \x01(\v2\x1c.saas.accounts.v1.DatasourceR\n" + - "datasource\"\xbe\x02\n" + + "datasource\"\xba\x03\n" + "\x13UpdateSourceRequest\x12\x1f\n" + "\x06org_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x05orgId\x12\x18\n" + "\x02id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x02id\x12 \n" + "\x06branch\x18\x03 \x01(\tB\b\xbaH\x05r\x03\x18\xff\x01R\x06branch\x12'\n" + "\x05paths\x18\x04 \x03(\tB\x11\xbaH\x0e\x92\x01\v\x10@\"\ar\x05\x10\x01\x18\x80\x04R\x05paths\x12Y\n" + "\x0ffile_extensions\x18\x05 \x03(\tB0\xbaH-\x92\x01*\x10 \"&r$\x10\x02\x18!2\x1e^\\.[A-Za-z0-9][A-Za-z0-9._-]*$R\x0efileExtensions\x12F\n" + - "\bexpected\x18\x06 \x01(\v2\".saas.accounts.v1.SourceFetchScopeB\x06\xbaH\x03\xc8\x01\x01R\bexpected\"\x98\x01\n" + + "\bexpected\x18\x06 \x01(\v2\".saas.accounts.v1.SourceFetchScopeB\x06\xbaH\x03\xc8\x01\x01R\bexpected\x12<\n" + + "\x10include_patterns\x18\a \x03(\tB\x11\xbaH\x0e\x92\x01\v\x10\x10\"\ar\x05\x10\x01\x18\x80\x02R\x0fincludePatterns\x12<\n" + + "\x10exclude_patterns\x18\b \x03(\tB\x11\xbaH\x0e\x92\x01\v\x10\x10\"\ar\x05\x10\x01\x18\x80\x02R\x0fexcludePatterns\"\x90\x02\n" + "\x10SourceFetchScope\x12 \n" + "\x06branch\x18\x01 \x01(\tB\b\xbaH\x05r\x03\x18\xff\x01R\x06branch\x12'\n" + "\x05paths\x18\x02 \x03(\tB\x11\xbaH\x0e\x92\x01\v\x10@\"\ar\x05\x10\x01\x18\x80\x04R\x05paths\x129\n" + "\x0ffile_extensions\x18\x03 \x03(\tB\x10\xbaH\r\x92\x01\n" + - "\x10 \"\x06r\x04\x10\x01\x18!R\x0efileExtensions\"k\n" + + "\x10 \"\x06r\x04\x10\x01\x18!R\x0efileExtensions\x12:\n" + + "\x10include_patterns\x18\x04 \x03(\tB\x0f\xbaH\f\x92\x01\t\x10\x10\"\x05r\x03\x18\x80\x02R\x0fincludePatterns\x12:\n" + + "\x10exclude_patterns\x18\x05 \x03(\tB\x0f\xbaH\f\x92\x01\t\x10\x10\"\x05r\x03\x18\x80\x02R\x0fexcludePatterns\"k\n" + "\x14UpdateSourceResponse\x12<\n" + "\n" + "datasource\x18\x01 \x01(\v2\x1c.saas.accounts.v1.DatasourceR\n" + diff --git a/module/services/accounts/proto/saas/accounts/v1/datasource.proto b/module/services/accounts/proto/saas/accounts/v1/datasource.proto index 8a75033fc..dac135132 100644 --- a/module/services/accounts/proto/saas/accounts/v1/datasource.proto +++ b/module/services/accounts/proto/saas/accounts/v1/datasource.proto @@ -189,7 +189,48 @@ message UploadDatasourceConfig { // AWS-style access key id. The matching secret access key is the credential. string access_key_id = 5; // Upper bound on objects fetched per sync. Zero applies the connector default. + // Only objects that pass the filters below count against it. uint32 max_objects = 6; + // Which keys under the prefix are ingested. Each pattern is an RE2 regular + // expression matched against the object key with the prefix removed, + // unanchored and case-sensitive (anchor with ^ and $, fold case with (?i)). + // An empty include admits every key; an exclude match always wins. Applied + // when the prefix is listed, before anything is fetched, so an excluded + // object is never read and never counts against max_objects. An invalid + // expression is refused when the source is added or updated, never at sync: + // the call fails INVALID_ARGUMENT with a google.rpc.BadRequest violation, and + // a message that starts with the field's path, e.g. + // "upload.include_patterns[1]: …". + repeated string include_patterns = 7 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: { + min_len: 1 + max_len: 256 + } + } + }]; + repeated string exclude_patterns = 8 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: { + min_len: 1 + max_len: 256 + } + } + }]; + // Case-insensitive key suffixes, e.g. [".md", ".pdf"], intersected with the + // patterns. Empty admits every type. GitHub's file_extensions semantics. + repeated string file_extensions = 9 [(buf.validate.field).repeated = { + max_items: 32 + items: { + string: { + min_len: 2 + max_len: 33 + pattern: "^\\.[A-Za-z0-9][A-Za-z0-9._-]*$" + } + } + }]; } // Datasource is one connected external datasource. It is org-scoped and carries @@ -437,6 +478,27 @@ message UpdateSourceRequest { // sole reason that the source had reconciled since the form was opened. What // an editor needs guarded is what they were looking at. SourceFetchScope expected = 6 [(buf.validate.field).required = true]; + // An object-storage source's key patterns (see UploadDatasourceConfig), + // replaced whole like every field here. Its file_extensions is field 5; its + // branch and paths must be empty. Refused for a github source. + repeated string include_patterns = 7 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: { + min_len: 1 + max_len: 256 + } + } + }]; + repeated string exclude_patterns = 8 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: { + min_len: 1 + max_len: 256 + } + } + }]; } // SourceFetchScope is what a source fetches — the three values UpdateSource @@ -467,6 +529,17 @@ message SourceFetchScope { max_len: 33 } } + }]; repeated string include_patterns = 4 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: {max_len: 256} + } + }]; + repeated string exclude_patterns = 5 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: {max_len: 256} + } }]; } diff --git a/module/services/frontend/code/packages/codefly-ui/src/__tests__/no-raw-type-primitives.test.ts b/module/services/frontend/code/packages/codefly-ui/src/__tests__/no-raw-type-primitives.test.ts index 5d4c3d36b..790d6254b 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/__tests__/no-raw-type-primitives.test.ts +++ b/module/services/frontend/code/packages/codefly-ui/src/__tests__/no-raw-type-primitives.test.ts @@ -78,7 +78,7 @@ const SAAS_UI_BASELINE: Record = { // Lowered from 16 when the credential block moved into // `datasources/credential-mode.tsx`, which carries none: the shared copy // names its slots, so the debt left behind shrank rather than moving. - "datasources/connect-github-form.tsx": 11, + "datasources/connect-github-form.tsx": 10, "datasources/datasources-panel.tsx": 15, }; const saasUiDir = resolve(srcDir, "../../saas-ui/src"); diff --git a/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json index 363d105db..49780c1dc 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json +++ b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contract.binpb", - "digest": "sha256:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7", + "digest": "sha256:a899ecff5c0e74ea89f34a8c761e58c79b50c0a5d2f5538e8e70c252233a0dbf", "services": [ { "name": "APIKeyService", diff --git a/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb b/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb index 590c0a8eb1d2d00c9520361083a0d0e6c244e78f..db3861fdbaf6b58c3a0e037eba6febbf3fd3512f 100644 GIT binary patch delta 42298 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 |F3G)MHv6KavNl>$n-tk$2`NgG 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 & { * @generated from field: repeated string file_extensions = 3; */ fileExtensions: string[]; + + /** + * @generated from field: repeated string include_patterns = 4; + */ + includePatterns: string[]; + + /** + * @generated from field: repeated string exclude_patterns = 5; + */ + excludePatterns: string[]; }; /** diff --git a/module/services/frontend/code/packages/saas-ui/README.md b/module/services/frontend/code/packages/saas-ui/README.md index 428c2bcca..c872f61ae 100644 --- a/module/services/frontend/code/packages/saas-ui/README.md +++ b/module/services/frontend/code/packages/saas-ui/README.md @@ -385,9 +385,19 @@ const directory = usePrincipalDirectory(); // loading | ready | refused | failed in its own shell — a line in a header, say — without re-deriving them from the phase names and getting the terminal cases wrong. `` is the card, and `useSourceSync(client, orgId, source)` the read. +- `` — endpoint (https only), + region, bucket, prefix, the key filter (include and exclude RE2 patterns, one + per line, over the key after the prefix; file types as on GitHub), access key + id, the secret access key the host encrypts at receipt, and the target + collection. It states that a bucket has no per-object access list, so one + source is one collection with one set of readers. A host refusal of a pattern + leads with the field it names and is shown under that field. Map its values + with `objectStorageInputFrom(orgId, values)` for the client's optional + `addObjectStorageSource`. Nothing in the kit mounts it yet: the host refuses + new object-storage sources until the provider is conformant (#1211). - Hooks over a `DatasourceClient`: `useListSources`, `useAddGitHubSource`, - `useUpdateSource`, `useSyncSource`, `useDeleteSource`, `useAccessibleScopes`, - `useMyAccountLinks`. + `useAddObjectStorageSource`, `useUpdateSource`, `useSyncSource`, + `useDeleteSource`, `useAccessibleScopes`, `useMyAccountLinks`. - ``, behind the row's **Edit**, changes what a connected source fetches: its branch and its path and file-type filters, bound by the same schema the connect form uses. It offers the connect diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/connect-object-storage-form.test.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/connect-object-storage-form.test.tsx new file mode 100644 index 000000000..2c43df225 --- /dev/null +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/connect-object-storage-form.test.tsx @@ -0,0 +1,103 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ConnectObjectStorageForm, + objectStorageInputFrom, +} from "../connect-object-storage-form.js"; + +afterEach(cleanup); + +function fill(label: string, value: string) { + fireEvent.change(screen.getByLabelText(label), { target: { value } }); +} + +function fillRequired() { + fill("Endpoint", "https://store.example.com"); + fill("Region", "us-east-1"); + fill("Bucket", "acme-docs"); + fill("Access key ID", "AKIAEXAMPLE"); + fill("Secret access key", "example-secret"); + fill("Target collection", "Handbook"); +} + +describe("ConnectObjectStorageForm", () => { + it("takes key patterns and file types, one pattern per line", async () => { + const onSubmit = vi.fn(); + render( + {}} + isPending={false} + />, + ); + fillRequired(); + fill("Prefix (optional)", "kb/"); + // A pattern may hold a comma; only a line ends one. + fill("Include keys matching (optional)", "^guides/\n\\.md$\n^v{1,3}/"); + fill("Exclude keys matching (optional)", "^drafts/"); + fill("File types (optional)", ".MD, .pdf"); + fireEvent.click(screen.getByRole("button", { name: "Connect bucket" })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(objectStorageInputFrom("org-1", onSubmit.mock.calls[0][0])).toEqual({ + orgId: "org-1", + endpoint: "https://store.example.com", + region: "us-east-1", + bucket: "acme-docs", + prefix: "kb/", + includePatterns: ["^guides/", "\\.md$", "^v{1,3}/"], + excludePatterns: ["^drafts/"], + fileExtensions: [".md", ".pdf"], + accessKeyId: "AKIAEXAMPLE", + secretAccessKey: "example-secret", + targetCollection: "Handbook", + boundaryNodeId: undefined, + }); + }); + + it("shows the host's refusal of a pattern under the field it names", () => { + const refusal = + 'upload.exclude_patterns[0]: "^(?!drafts/)" is not a valid RE2 expression: invalid or unsupported Perl syntax: `(?!`'; + render( + {}} + onCancel={() => {}} + isPending={false} + errorMessage={refusal} + />, + ); + const field = screen.getByLabelText("Exclude keys matching (optional)"); + expect(field.getAttribute("aria-invalid")).toBe("true"); + const described = document.getElementById( + field.getAttribute("aria-describedby") ?? "", + ); + expect(described?.textContent).toBe(refusal); + // Placed once, under its field: not repeated in the form's own slot. + expect(screen.getAllByText(refusal)).toHaveLength(1); + expect( + screen + .getByLabelText("Include keys matching (optional)") + .getAttribute("aria-invalid"), + ).toBe("false"); + }); + + it("keeps a refusal that names no key-filter field in the form's own slot", () => { + render( + {}} + onCancel={() => {}} + isPending={false} + errorMessage="the store refused the access key" + />, + ); + expect(screen.getByRole("alert").textContent).toBe( + "the store refused the access key", + ); + }); +}); diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/gateway.test.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/gateway.test.tsx index c9114c216..622078f32 100644 --- a/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/gateway.test.tsx +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/gateway.test.tsx @@ -177,6 +177,20 @@ describe("createDatasourceClient", () => { expected: { branch: "main", paths: [], fileExtensions: [] }, }), deleteSource: () => client.deleteSource("org-1", "ds-1"), + addObjectStorageSource: () => + client.addObjectStorageSource!({ + orgId: "org-1", + endpoint: "https://store.example.com", + region: "us-east-1", + bucket: "acme-docs", + prefix: "kb/", + includePatterns: ["^guides/"], + excludePatterns: [], + fileExtensions: [], + accessKeyId: "AKIAEXAMPLE", + secretAccessKey: "secret", + targetCollection: "Example", + }), // The stub answers {} to everything; an operation whose answer must // carry a record rejects on it, after the call this test counts. beginAccountLink: () => diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/collection-field.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/collection-field.tsx new file mode 100644 index 000000000..ca8a2f39b --- /dev/null +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/collection-field.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { Input, Label } from "@codefly-dev/ui/layout"; +import type { UseFormRegisterReturn } from "react-hook-form"; +import { fieldErrorClass as errorClass } from "./credential-mode.js"; +import type { CollectionAccessView } from "./types.js"; + +interface CollectionFieldProps { + idFor: (name: string) => string; + collections?: CollectionAccessView[]; + readableNodeIds?: string[]; + collectionError?: boolean; + boundaryNodeId?: string; + targetCollection?: string; + /** An existing collection was picked, or "" to create one. */ + onSelect: (nodeId: string, label: string) => void; + inputProps: UseFormRegisterReturn; + error?: string; +} + +/** + * Where a new source's content lands: an existing collection, or a label to + * create one under. Every connect form shares it, so who can read the result + * is told the same way whichever provider is being connected. + */ +export function CollectionField({ + idFor, + collections, + readableNodeIds, + collectionError, + boundaryNodeId, + targetCollection, + onSelect, + inputProps, + error, +}: CollectionFieldProps) { + const selected = collections?.find( + (collection) => collection.nodeId === boundaryNodeId, + ); + const named = + selected ?? + collections?.find((collection) => collection.label === targetCollection); + return ( +
+ {collections && ( + <> + + + + )} + + +

+ Connecting grants no read access to the creator or a default team. + Organization administrators explicitly grant members or teams read + access. Platform administrators read every collection without a grant. +

+ {collectionError ? ( +

Couldn’t inspect collection grants.

+ ) : named ? ( +

+ Readers:{" "} + {named.grants.map((grant) => grant.subjectLabel).join(", ") || + "No collection read grants"} + .{" "} + {readableNodeIds === undefined + ? "Your read permission is unresolved." + : readableNodeIds.includes(named.nodeId) + ? "You can read this collection." + : "You do not have read access. Ask an administrator for access."} +

+ ) : ( +

+ {collections + ? "New collections have no read grants. Your read permission must be checked after creation." + : "Collection grants are unresolved. Inspect them in the host after connecting."} +

+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/connect-github-form.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/connect-github-form.tsx index 88f35524c..8d9cdc20b 100644 --- a/module/services/frontend/code/packages/saas-ui/src/datasources/connect-github-form.tsx +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/connect-github-form.tsx @@ -4,6 +4,7 @@ import { Button, Input, Label, Textarea } from "@codefly-dev/ui/layout"; import { zodResolver } from "@hookform/resolvers/zod"; import { useId } from "react"; import { useForm, useWatch } from "react-hook-form"; +import { CollectionField } from "./collection-field.js"; import { AccessTokenField, AppInstallPrompt, @@ -78,12 +79,6 @@ export function ConnectGitHubForm({ control: form.control, name: "targetCollection", }); - const selected = collections?.find( - (collection) => collection.nodeId === boundaryNodeId, - ); - const named = - selected ?? - collections?.find((collection) => collection.label === targetCollection); return (
-
- {collections && ( - <> - - - - )} - - -

- Connecting grants no read access to the creator or a default team. - Organization administrators explicitly grant members or teams read - access. Platform administrators read every collection without a - grant. -

- {collectionError ? ( -

Couldn’t inspect collection grants.

- ) : named ? ( -

- Readers:{" "} - {named.grants.map((grant) => grant.subjectLabel).join(", ") || - "No collection read grants"} - .{" "} - {readableNodeIds === undefined - ? "Your read permission is unresolved." - : readableNodeIds.includes(named.nodeId) - ? "You can read this collection." - : "You do not have read access. Ask an administrator for access."} -

- ) : ( -

- {collections - ? "New collections have no read grants. Your read permission must be checked after creation." - : "Collection grants are unresolved. Inspect them in the host after connecting."} -

- )} - {errors.targetCollection && ( -

- {errors.targetCollection.message} -

- )} -
+ { + form.setValue("boundaryNodeId", nodeId); + form.setValue("targetCollection", label); + }} + inputProps={form.register("targetCollection")} + {...(errors.targetCollection?.message + ? { error: errors.targetCollection.message } + : {})} + /> {method === "pat" && ( + value.toLowerCase(), + ), + accessKeyId: values.accessKeyId, + secretAccessKey: values.secretAccessKey, + targetCollection: values.targetCollection, + boundaryNodeId: values.boundaryNodeId || undefined, + }; +} + +interface ConnectObjectStorageFormProps { + collections?: CollectionAccessView[]; + readableNodeIds?: string[]; + collectionError?: boolean; + onSubmit: (values: ConnectObjectStorageValues) => void; + onCancel: () => void; + isPending: boolean; + errorMessage?: string; +} + +const textFields = [ + { + name: "endpoint", + label: "Endpoint", + placeholder: "https://s3.us-east-1.amazonaws.com", + }, + { name: "region", label: "Region", placeholder: "us-east-1" }, + { name: "bucket", label: "Bucket", placeholder: "acme-knowledge" }, + { name: "prefix", label: "Prefix (optional)", placeholder: "handbook/" }, + { name: "accessKeyId", label: "Access key ID", placeholder: "" }, +] as const; + +export function ConnectObjectStorageForm({ + collections, + readableNodeIds, + collectionError, + onSubmit, + onCancel, + isPending, + errorMessage, +}: ConnectObjectStorageFormProps) { + const fieldId = useId(); + const idFor = (name: string) => `${fieldId}-${name}`; + const form = useForm({ + resolver: zodResolver(connectObjectStorageSchema), + defaultValues: { + endpoint: "", + region: "", + bucket: "", + prefix: "", + includePatterns: "", + excludePatterns: "", + fileExtensions: "", + accessKeyId: "", + secretAccessKey: "", + boundaryNodeId: "", + targetCollection: "", + }, + }); + const { errors } = form.formState; + // The host refuses an invalid pattern by naming its field; that refusal is + // shown under the field, and only an unplaced one in the form's own slot. + const refusedField = refusedKeyFilterField(errorMessage); + const filterError = ( + name: "includePatterns" | "excludePatterns" | "fileExtensions", + ) => + errors[name]?.message ?? (refusedField === name ? errorMessage : undefined); + const filters = [ + { + name: "includePatterns", + label: "Include keys matching (optional)", + help: "One RE2 pattern per line, matched against the key after the prefix, e.g. ^guides/ or \\.md$. Empty includes every key.", + }, + { + name: "excludePatterns", + label: "Exclude keys matching (optional)", + help: "One RE2 pattern per line. An excluded key is never fetched, even if it is also included.", + }, + ] as const; + const boundaryNodeId = useWatch({ + control: form.control, + name: "boundaryNodeId", + }); + const targetCollection = useWatch({ + control: form.control, + name: "targetCollection", + }); + + return ( +
+
+
+

Connect object storage

+

+ Register a bucket as a data source. The objects under its prefix + that pass its key filter are pulled on sync. Everyone who may read + the collection reads every one of them: a bucket has no per-object + access list, so the bucket, prefix and filter make one collection + with one set of readers. For another audience, connect another + source with its own filter. +

+
+ +
+ {textFields.map((field) => { + const error = errors[field.name]?.message; + return ( +
+ + + {error && ( +

+ {error} +

+ )} +
+ ); + })} + + {filters.map((field) => { + const error = filterError(field.name); + return ( +
+ +