diff --git a/blob/access.go b/blob/access.go new file mode 100644 index 0000000..f228c94 --- /dev/null +++ b/blob/access.go @@ -0,0 +1,210 @@ +package blob + +import ( + "context" + "errors" + "fmt" + "maps" + + blobpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/blob/v1" + commonpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/common/v1" + + "github.com/code-payments/flipcash2-server/chat" +) + +// ErrInvalidGrant is returned by AccessStore methods when a grant — or the key +// used to look one up — is not well-formed: a missing blob id, an unknown +// principal type, an empty principal id, or an unknown permission. +var ErrInvalidGrant = errors.New("invalid grant") + +// PrincipalType identifies the kind of subject a grant is made to. It is the +// extension point for "where a blob can be accessed from": each access surface +// (a chat today; a feed, a profile, a public link later) is a principal type +// with its own server-side membership check. The constants are persisted by +// value, so their numbering is significant and must not be reordered. +type PrincipalType int + +const ( + PrincipalTypeUnknown PrincipalType = iota + + // PrincipalTypeUser is a single user, identified by their user id. A user + // principal is resolved by direct identity, with no membership lookup. + PrincipalTypeUser + + // PrincipalTypeChat is every current member of a chat, identified by the chat + // id. A chat principal is resolved against live chat membership, so a single + // grant covers the whole chat and stays correct as membership changes. + PrincipalTypeChat +) + +// Principal is the typed subject a grant is made to. ID is the type-specific +// identifier bytes: a user id for PrincipalTypeUser, a chat id for +// PrincipalTypeChat. +type Principal struct { + Type PrincipalType + ID []byte +} + +// PrincipalForUser returns the principal for a single user. +func PrincipalForUser(userID *commonpb.UserId) Principal { + return Principal{Type: PrincipalTypeUser, ID: userID.Value} +} + +// PrincipalForChat returns the principal for the members of a chat. +func PrincipalForChat(chatID *commonpb.ChatId) Principal { + return Principal{Type: PrincipalTypeChat, ID: chatID.Value} +} + +func (p Principal) validate() error { + switch p.Type { + case PrincipalTypeUser, PrincipalTypeChat: + default: + return fmt.Errorf("%w: unknown principal type %d", ErrInvalidGrant, p.Type) + } + if len(p.ID) == 0 { + return fmt.Errorf("%w: empty principal id", ErrInvalidGrant) + } + return nil +} + +// Permission is what a grant authorizes a principal to do with a blob. READ is +// the only permission today; WRITE, DELETE, and SHARE can be added without +// changing the store. Persisted by value, so the numbering is significant. +type Permission int + +const ( + PermissionUnknown Permission = iota + + // PermissionRead authorizes resolving the blob and minting a download URL for + // it — the access GetBlobs grants. + PermissionRead +) + +func (p Permission) validate() error { + switch p { + case PermissionRead: + return nil + default: + return fmt.Errorf("%w: unknown permission %d", ErrInvalidGrant, p) + } +} + +// Grant authorizes a Principal to exercise a Permission on a blob. It is the ACL +// entry: its existence is the authorization and it carries no other state. A +// blob's Owner holds every permission implicitly and needs no grant. Grants are +// made against the ORIGINAL blob id; server-derived renditions inherit their +// original's grants. +type Grant struct { + BlobID *blobpb.BlobId + Principal Principal + Permission Permission +} + +// Validate reports whether the grant is well-formed. Stores call it before +// persisting or looking up a grant so a malformed principal or permission can +// never be written or silently miss. +func (g *Grant) Validate() error { + if g == nil || g.BlobID == nil || len(g.BlobID.Value) == 0 { + return fmt.Errorf("%w: missing blob id", ErrInvalidGrant) + } + if err := g.Principal.validate(); err != nil { + return err + } + return g.Permission.validate() +} + +// AccessStore persists blob ACL grants. A grant's existence authorizes its +// principal to exercise its permission on the blob; there is no other state. +// +// The store resolves a grant by its exact (blob, principal, permission) key and +// performs no membership resolution, so it never depends on the chat — or any +// other principal — subsystem. Authorizing a concrete user against a non-user +// principal (e.g. checking chat membership for a PrincipalTypeChat grant) is the +// caller's responsibility. +type AccessStore interface { + // Grant records that the grant's principal may exercise its permission on its + // blob. It is idempotent: re-granting the same (blob, principal, permission) + // is a no-op. It returns ErrInvalidGrant if the grant is not well-formed. + Grant(ctx context.Context, g *Grant) error + + // HasGrant reports whether a grant exists for the exact (blob, principal, + // permission) triple. A missing grant is (false, nil), not an error. It + // returns ErrInvalidGrant if the lookup key is not well-formed. + HasGrant(ctx context.Context, blobID *blobpb.BlobId, p Principal, perm Permission) (bool, error) + + // Revoke removes a grant. It is idempotent: revoking a grant that does not + // exist is a no-op. It returns ErrInvalidGrant if the key is not well-formed. + Revoke(ctx context.Context, blobID *blobpb.BlobId, p Principal, perm Permission) error +} + +// PrincipalResolver reports whether a concrete user is covered by a principal — +// that is, whether a grant made to that principal authorizes the user. A +// PrincipalTypeUser principal is covered by identity; a group principal such as +// PrincipalTypeChat is covered by live membership. +// +// It is the single extension point the read path consults to resolve a grant's +// principal, so supporting a new access surface is a matter of adding a +// PrincipalType and teaching the resolver about it — the server does not change. +// Defining it here, rather than depending on the chat (or any future scope) +// subsystem, keeps blob decoupled from what backs each principal type; the +// wiring supplies a resolver that knows how to resolve them (e.g. mapping a +// PrincipalTypeChat to chat membership). +type PrincipalResolver interface { + Covers(ctx context.Context, principal Principal, user *commonpb.UserId) (bool, error) +} + +// CompositeResolver is the top-level PrincipalResolver: it routes each principal +// to the domain resolver registered for its type. The server holds one of these, +// so adding an access surface is a matter of registering its resolver here — the +// read path does not change. A principal whose type has no registered resolver +// is not covered, mirroring how an unknown access scope is treated. +type CompositeResolver struct { + byType map[PrincipalType]PrincipalResolver +} + +// NewCompositeResolver returns a CompositeResolver that dispatches a principal to +// the resolver registered for its PrincipalType. The routing table is copied, so +// later mutation of the passed map does not affect the resolver. +func NewCompositeResolver(byType map[PrincipalType]PrincipalResolver) PrincipalResolver { + routes := make(map[PrincipalType]PrincipalResolver, len(byType)) + maps.Copy(routes, byType) + return &CompositeResolver{byType: routes} +} + +// Covers routes principal to the resolver registered for its type and returns +// that resolver's decision. A principal whose type has no registered resolver is +// not covered (false, nil) — an unroutable scope authorizes nothing. +func (r *CompositeResolver) Covers(ctx context.Context, principal Principal, user *commonpb.UserId) (bool, error) { + resolver, ok := r.byType[principal.Type] + if !ok { + return false, nil + } + return resolver.Covers(ctx, principal, user) +} + +// ChatResolver is the PrincipalResolver for chat-scoped grants: a user is +// covered by a PrincipalTypeChat principal iff they are a member of the chat the +// principal identifies. It resolves membership directly against the chat store. +type ChatResolver struct { + chats chat.Store +} + +// NewChatResolver returns a ChatResolver backed by the given chat store. +func NewChatResolver(chats chat.Store) PrincipalResolver { + return &ChatResolver{chats: chats} +} + +// Covers reports whether user is covered by principal. A PrincipalTypeChat +// principal is covered iff user is a member of the chat identified by the +// principal id. Any other principal type is outside this resolver's scope — it +// is chat-only — so it reports not-covered rather than guessing; supporting +// another scope is a matter of layering a different resolver, not changing this +// one. +func (r *ChatResolver) Covers(ctx context.Context, principal Principal, user *commonpb.UserId) (bool, error) { + switch principal.Type { + case PrincipalTypeChat: + return r.chats.IsMember(ctx, &commonpb.ChatId{Value: principal.ID}, user) + default: + return false, nil + } +} diff --git a/blob/access_test.go b/blob/access_test.go new file mode 100644 index 0000000..abafc19 --- /dev/null +++ b/blob/access_test.go @@ -0,0 +1,108 @@ +package blob + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + + commonpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/common/v1" + + "github.com/code-payments/flipcash2-server/chat" + chat_memory "github.com/code-payments/flipcash2-server/chat/memory" + "github.com/code-payments/flipcash2-server/model" +) + +// stubResolver is a controllable PrincipalResolver that records how often it is +// consulted, so routing can be asserted. +type stubResolver struct { + result bool + err error + calls int +} + +func (s *stubResolver) Covers(context.Context, Principal, *commonpb.UserId) (bool, error) { + s.calls++ + return s.result, s.err +} + +func TestCompositeResolver_Routes(t *testing.T) { + ctx := context.Background() + user := model.MustGenerateUserID() + + chatStub := &stubResolver{result: true} + r := NewCompositeResolver(map[PrincipalType]PrincipalResolver{ + PrincipalTypeChat: chatStub, + }) + + // A chat principal routes to the registered resolver and returns its decision. + ok, err := r.Covers(ctx, Principal{Type: PrincipalTypeChat, ID: []byte("chat")}, user) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, 1, chatStub.calls) + + // A principal whose type has no registered resolver is not covered, and no + // resolver is consulted. + ok, err = r.Covers(ctx, Principal{Type: PrincipalTypeUser, ID: []byte("user")}, user) + require.NoError(t, err) + require.False(t, ok) + require.Equal(t, 1, chatStub.calls) + + // The routed resolver's error propagates. + chatStub.err = errors.New("boom") + _, err = r.Covers(ctx, Principal{Type: PrincipalTypeChat, ID: []byte("chat")}, user) + require.Error(t, err) +} + +func TestCompositeResolver_CopiesRoutingTable(t *testing.T) { + ctx := context.Background() + user := model.MustGenerateUserID() + + chatStub := &stubResolver{result: true} + input := map[PrincipalType]PrincipalResolver{PrincipalTypeChat: chatStub} + r := NewCompositeResolver(input) + + // Mutating the input map after construction does not change routing. + delete(input, PrincipalTypeChat) + + ok, err := r.Covers(ctx, Principal{Type: PrincipalTypeChat, ID: []byte("chat")}, user) + require.NoError(t, err) + require.True(t, ok) +} + +func TestChatResolver_Covers(t *testing.T) { + ctx := context.Background() + + chats := chat_memory.NewInMemory() + member := model.MustGenerateUserID() + stranger := model.MustGenerateUserID() + chatID := chat.MustDeriveDmChatID(member, stranger) + require.NoError(t, chats.PutChat(ctx, &chat.Chat{ + ID: chatID, + Members: []*commonpb.UserId{member}, + })) + + r := NewChatResolver(chats) + + // A member of the chat is covered by the chat principal. + ok, err := r.Covers(ctx, PrincipalForChat(chatID), member) + require.NoError(t, err) + require.True(t, ok) + + // A non-member is not covered. + ok, err = r.Covers(ctx, PrincipalForChat(chatID), stranger) + require.NoError(t, err) + require.False(t, ok) + + // An unknown chat is not covered (IsMember reports false without error). + unknownChat := chat.MustDeriveDmChatID(member, model.MustGenerateUserID()) + ok, err = r.Covers(ctx, PrincipalForChat(unknownChat), member) + require.NoError(t, err) + require.False(t, ok) + + // A non-chat principal is outside this resolver's scope and is never covered. + ok, err = r.Covers(ctx, PrincipalForUser(member), member) + require.NoError(t, err) + require.False(t, ok) +} diff --git a/blob/dynamodb/access.go b/blob/dynamodb/access.go new file mode 100644 index 0000000..7594628 --- /dev/null +++ b/blob/dynamodb/access.go @@ -0,0 +1,117 @@ +package dynamodb + +import ( + "context" + "encoding/hex" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + + blobpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/blob/v1" + + "github.com/code-payments/flipcash2-server/blob" +) + +// The access store uses a single ACL table, one item per access-control entry +// (ACE) keyed by pk = "blob#" (the blob the entry is on) and +// sk = "###" (the effect a principal has +// on a permission). An entry's existence is the authorization, so the item +// carries no other attributes and the table has no secondary indexes. Reads are +// exact-key point gets, so the store performs no membership resolution and never +// depends on the chat subsystem. Entries are durable: unlike pending blobs they +// carry no TTL and persist until explicitly revoked. +// +// Only ALLOW effects are written today — the domain AccessStore is allow-only — +// but the effect leads the sort key so a future DENY effect lands as sibling +// entries (distinct items, point-gettable and queryable via +// begins_with(sk, "#")) with no change to this table. +const ( + attrSK = "sk" // composite "###", the entry + + // effectAllow is the only effect written today. It leads the sort key, ahead + // of the permission and principal, so a future DENY effect slots in as sibling + // entries without a migration. + effectAllow = 1 +) + +type accessStore struct { + client *dynamodb.Client + table string +} + +// NewAccessInDynamoDB returns a blob.AccessStore backed by the given DynamoDB +// ACL table. Use CreateTables to provision it. +func NewAccessInDynamoDB(client *dynamodb.Client, table string) blob.AccessStore { + return &accessStore{ + client: client, + table: table, + } +} + +func (s *accessStore) Grant(ctx context.Context, g *blob.Grant) error { + if err := g.Validate(); err != nil { + return err + } + + // The entry is keyless beyond its identity, so an unconditional PutItem is the + // idempotent grant: re-granting overwrites the same item with itself. + _, err := s.client.PutItem(ctx, &dynamodb.PutItemInput{ + TableName: aws.String(s.table), + Item: aceKey(g.BlobID, g.Principal, g.Permission), + }) + return err +} + +func (s *accessStore) HasGrant(ctx context.Context, blobID *blobpb.BlobId, p blob.Principal, perm blob.Permission) (bool, error) { + if err := (&blob.Grant{BlobID: blobID, Principal: p, Permission: perm}).Validate(); err != nil { + return false, err + } + + out, err := s.client.GetItem(ctx, &dynamodb.GetItemInput{ + TableName: aws.String(s.table), + Key: aceKey(blobID, p, perm), + ProjectionExpression: aws.String(attrPK), + }) + if err != nil { + return false, err + } + return len(out.Item) > 0, nil +} + +func (s *accessStore) Revoke(ctx context.Context, blobID *blobpb.BlobId, p blob.Principal, perm blob.Permission) error { + if err := (&blob.Grant{BlobID: blobID, Principal: p, Permission: perm}).Validate(); err != nil { + return err + } + + _, err := s.client.DeleteItem(ctx, &dynamodb.DeleteItemInput{ + TableName: aws.String(s.table), + Key: aceKey(blobID, p, perm), + }) + return err +} + +func (s *accessStore) reset() { + if err := clearTable(context.Background(), s.client, s.table, []string{attrPK, attrSK}); err != nil { + panic(err) + } +} + +// aceKey builds the full primary key (pk + sk) for an access-control entry. It is +// the item itself on a Put (an entry has no other attributes) and the lookup key +// on a Get/Delete. +func aceKey(blobID *blobpb.BlobId, p blob.Principal, perm blob.Permission) map[string]types.AttributeValue { + return map[string]types.AttributeValue{ + attrPK: avS(blobPK(blobID)), + attrSK: avS(aceSK(p, perm)), + } +} + +// aceSK encodes the sort key for an access-control entry: the effect, then the +// permission, principal type, and principal id. Only effectAllow is written +// today; leading with the effect leaves room for a future DENY without a +// migration. +func aceSK(p blob.Principal, perm blob.Permission) string { + return fmt.Sprintf("%d#%d#%d#%s", effectAllow, int(perm), int(p.Type), hex.EncodeToString(p.ID)) +} diff --git a/blob/dynamodb/access_test.go b/blob/dynamodb/access_test.go new file mode 100644 index 0000000..8241c1e --- /dev/null +++ b/blob/dynamodb/access_test.go @@ -0,0 +1,24 @@ +//go:build integration + +package dynamodb + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/code-payments/flipcash2-server/blob/tests" +) + +const aclTable = "blob_acls_test" + +func TestBlobAccess_DynamoDBStore(t *testing.T) { + require.NoError(t, CreateTables(context.Background(), testEnv.Client, blobsTable, aclTable)) + + testStore := NewAccessInDynamoDB(testEnv.Client, aclTable) + teardown := func() { + testStore.(*accessStore).reset() + } + tests.RunAccessStoreTests(t, testStore, teardown) +} diff --git a/blob/dynamodb/server_test.go b/blob/dynamodb/server_test.go index e7364e7..ff0204d 100644 --- a/blob/dynamodb/server_test.go +++ b/blob/dynamodb/server_test.go @@ -14,16 +14,18 @@ import ( ) func TestBlob_DynamoDBServer(t *testing.T) { - require.NoError(t, CreateTables(context.Background(), testEnv.Client, blobsTable)) + require.NoError(t, CreateTables(context.Background(), testEnv.Client, blobsTable, aclTable)) accounts := account_memory.NewInMemory() blobs := NewInDynamoDB(testEnv.Client, blobsTable) + access := NewAccessInDynamoDB(testEnv.Client, aclTable) // The object storage is always the in-memory fake; only the metadata store is // exercised against DynamoDB here. Its keys are per-blob random ids, so leftover // objects across test funcs never collide and it needs no reset. storage := blob_memory.NewInMemoryStorage() teardown := func() { blobs.(*store).reset() + access.(*accessStore).reset() } - tests.RunServerTests(t, accounts, blobs, storage, storage.SimulateUpload, teardown) + tests.RunServerTests(t, accounts, blobs, storage, access, storage.SimulateUpload, teardown) } diff --git a/blob/dynamodb/store_test.go b/blob/dynamodb/store_test.go index 4ff4e76..4ea9548 100644 --- a/blob/dynamodb/store_test.go +++ b/blob/dynamodb/store_test.go @@ -14,7 +14,7 @@ import ( const blobsTable = "blobs_test" func TestBlob_DynamoDBStore(t *testing.T) { - require.NoError(t, CreateTables(context.Background(), testEnv.Client, blobsTable)) + require.NoError(t, CreateTables(context.Background(), testEnv.Client, blobsTable, aclTable)) testStore := NewInDynamoDB(testEnv.Client, blobsTable) teardown := func() { diff --git a/blob/dynamodb/table.go b/blob/dynamodb/table.go index 7bf53cf..b5f6d89 100644 --- a/blob/dynamodb/table.go +++ b/blob/dynamodb/table.go @@ -10,11 +10,21 @@ import ( "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" ) -// CreateTables provisions the blobs table: one item per blob keyed by +// CreateTables provisions the blob domain's DynamoDB tables — the blobs table +// and the ACL table — and is the single entry point for setting them up. It is +// idempotent (existing tables are left as-is) and blocks until both are ACTIVE. +func CreateTables(ctx context.Context, client *dynamodb.Client, blobsTable, aclTable string) error { + if err := createBlobsTable(ctx, client, blobsTable); err != nil { + return err + } + return createACLTable(ctx, client, aclTable) +} + +// createBlobsTable provisions the blobs table: one item per blob keyed by // pk = "blob#", with on-demand billing and a sparse renditions_by_parent // GSI (hash = parent_id) for listing an ORIGINAL's renditions. It is idempotent // (an existing table is left as-is) and blocks until the table is ACTIVE. -func CreateTables(ctx context.Context, client *dynamodb.Client, blobsTable string) error { +func createBlobsTable(ctx context.Context, client *dynamodb.Client, blobsTable string) error { _, err := client.CreateTable(ctx, &dynamodb.CreateTableInput{ TableName: aws.String(blobsTable), BillingMode: types.BillingModePayPerRequest, @@ -49,6 +59,38 @@ func CreateTables(ctx context.Context, client *dynamodb.Client, blobsTable strin return enableTTL(ctx, client, blobsTable) } +// createACLTable provisions the blob ACL table: one item per access-control +// entry keyed by pk = "blob#" and +// sk = "###", with on-demand billing. An +// entry's existence is the authorization, so there are no other attributes, no +// secondary indexes, and no TTL — entries are durable until explicitly revoked. +// It is idempotent (an existing table is left as-is) and blocks until the table +// is ACTIVE. +func createACLTable(ctx context.Context, client *dynamodb.Client, aclTable string) error { + _, err := client.CreateTable(ctx, &dynamodb.CreateTableInput{ + TableName: aws.String(aclTable), + BillingMode: types.BillingModePayPerRequest, + AttributeDefinitions: []types.AttributeDefinition{ + {AttributeName: aws.String(attrPK), AttributeType: types.ScalarAttributeTypeS}, + {AttributeName: aws.String(attrSK), AttributeType: types.ScalarAttributeTypeS}, + }, + KeySchema: []types.KeySchemaElement{ + {AttributeName: aws.String(attrPK), KeyType: types.KeyTypeHash}, + {AttributeName: aws.String(attrSK), KeyType: types.KeyTypeRange}, + }, + }) + if err != nil { + var inUse *types.ResourceInUseException + if !errors.As(err, &inUse) { + return err + } + // Already exists; still ensure it is ACTIVE below. + } + return dynamodb.NewTableExistsWaiter(client).Wait(ctx, &dynamodb.DescribeTableInput{ + TableName: aws.String(aclTable), + }, 2*time.Minute) +} + // enableTTL turns on DynamoDB's TTL feature against the expires_at attribute, so // blobs that never reach READY are reclaimed automatically. Enabling TTL when it // is already enabled is an error, so it is checked first to stay idempotent. diff --git a/blob/memory/access.go b/blob/memory/access.go new file mode 100644 index 0000000..20d1ce8 --- /dev/null +++ b/blob/memory/access.go @@ -0,0 +1,77 @@ +package memory + +import ( + "context" + "fmt" + "sync" + + blobpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/blob/v1" + + "github.com/code-payments/flipcash2-server/blob" +) + +type accessMemory struct { + sync.Mutex + + // grants is the set of present grant keys; a grant carries no state beyond + // its existence, so membership in this set is the authorization. + grants map[string]struct{} +} + +// NewInMemoryAccessStore returns an in-memory blob.AccessStore for tests. +func NewInMemoryAccessStore() blob.AccessStore { + return &accessMemory{ + grants: make(map[string]struct{}), + } +} + +func (m *accessMemory) Grant(_ context.Context, g *blob.Grant) error { + if err := g.Validate(); err != nil { + return err + } + + m.Lock() + defer m.Unlock() + + m.grants[grantKey(g.BlobID, g.Principal, g.Permission)] = struct{}{} + return nil +} + +func (m *accessMemory) HasGrant(_ context.Context, blobID *blobpb.BlobId, p blob.Principal, perm blob.Permission) (bool, error) { + if err := (&blob.Grant{BlobID: blobID, Principal: p, Permission: perm}).Validate(); err != nil { + return false, err + } + + m.Lock() + defer m.Unlock() + + _, ok := m.grants[grantKey(blobID, p, perm)] + return ok, nil +} + +func (m *accessMemory) Revoke(_ context.Context, blobID *blobpb.BlobId, p blob.Principal, perm blob.Permission) error { + if err := (&blob.Grant{BlobID: blobID, Principal: p, Permission: perm}).Validate(); err != nil { + return err + } + + m.Lock() + defer m.Unlock() + + delete(m.grants, grantKey(blobID, p, perm)) + return nil +} + +func (m *accessMemory) reset() { + m.Lock() + defer m.Unlock() + + m.grants = make(map[string]struct{}) +} + +// grantKey derives a unique map key for the (blob, principal, permission) +// triple. The permission and principal type are scalar values and the id bytes +// are length-delimited (via the %q on the raw bytes), so no two distinct triples +// collide. +func grantKey(blobID *blobpb.BlobId, p blob.Principal, perm blob.Permission) string { + return fmt.Sprintf("%q|%d|%d|%q", blobID.Value, int(perm), int(p.Type), p.ID) +} diff --git a/blob/memory/access_test.go b/blob/memory/access_test.go new file mode 100644 index 0000000..9f09648 --- /dev/null +++ b/blob/memory/access_test.go @@ -0,0 +1,15 @@ +package memory + +import ( + "testing" + + "github.com/code-payments/flipcash2-server/blob/tests" +) + +func TestBlobAccess_MemoryStore(t *testing.T) { + testStore := NewInMemoryAccessStore() + teardown := func() { + testStore.(*accessMemory).reset() + } + tests.RunAccessStoreTests(t, testStore, teardown) +} diff --git a/blob/memory/server_test.go b/blob/memory/server_test.go index d9dacb8..3e94078 100644 --- a/blob/memory/server_test.go +++ b/blob/memory/server_test.go @@ -10,10 +10,12 @@ import ( func TestBlob_MemoryServer(t *testing.T) { accounts := account_memory.NewInMemory() blobs := NewInMemory() + access := NewInMemoryAccessStore() storage := NewInMemoryStorage() teardown := func() { blobs.(*memory).reset() + access.(*accessMemory).reset() storage.reset() } - tests.RunServerTests(t, accounts, blobs, storage, storage.SimulateUpload, teardown) + tests.RunServerTests(t, accounts, blobs, storage, access, storage.SimulateUpload, teardown) } diff --git a/blob/server.go b/blob/server.go index de30ae9..30966ab 100644 --- a/blob/server.go +++ b/blob/server.go @@ -37,6 +37,12 @@ type Server struct { blobs Store storage ObjectStorage + // access holds the blob ACL grants; resolver resolves a grant's principal to + // concrete coverage (e.g. chat membership). Together they back the non-owner + // read path in GetBlobs. + access AccessStore + resolver PrincipalResolver + // moderator classifies uploaded image bytes during finalization. It is // optional; when nil, moderation is skipped. moderator moderation.Client @@ -52,6 +58,8 @@ func NewServer( accounts account.Store, blobs Store, storage ObjectStorage, + access AccessStore, + resolver PrincipalResolver, moderator moderation.Client, requireStaff bool, ) *Server { @@ -61,6 +69,8 @@ func NewServer( accounts: accounts, blobs: blobs, storage: storage, + access: access, + resolver: resolver, moderator: moderator, requireStaff: requireStaff, } @@ -293,9 +303,17 @@ func (s *Server) GetBlobs(ctx context.Context, req *blobpb.GetBlobsRequest) (*bl resolved := make([]*blobpb.Blob, 0, len(records)) for _, record := range records { - // Owner-only access (see the method comment): a record the caller does not - // own is skipped, leaving it indistinguishable from one that does not exist. - if record.Owner == nil || !bytes.Equal(record.Owner.Value, caller.Value) { + allowed, err := s.canRead(ctx, caller, record, req.Context) + if err != nil { + s.log.Warn("Failed to authorize blob", + zap.String("blob_id", blobIDString(record.ID)), + zap.Error(err), + ) + return nil, status.Error(codes.Internal, "failed to get blobs") + } + if !allowed { + // A record the caller may not read is skipped, leaving it + // indistinguishable from one that does not exist. continue } @@ -333,6 +351,58 @@ func (s *Server) GetBlobs(ctx context.Context, req *blobpb.GetBlobsRequest) (*bl return resp, nil } +// canRead reports whether caller may read record. The blob's owner always may, +// and needs no access context. Any other caller must present an access context +// that authorizes the blob: the blob must carry a read grant for the context's +// principal AND the caller must be covered by that principal (e.g. be a member +// of the chat). Both are required — coverage alone would let anyone in the scope +// read any blob id they can guess, and the grant alone would ignore who is +// asking. A caller who may not read is reported (false, nil) so GetBlobs skips +// the record, leaving it indistinguishable from one that does not exist. +func (s *Server) canRead(ctx context.Context, caller *commonpb.UserId, record *Blob, accessContext *blobpb.AccessContext) (bool, error) { + if record.Owner != nil && bytes.Equal(record.Owner.Value, caller.Value) { + return true, nil + } + if accessContext == nil { + // A non-owner read requires a context; without one the blob is unauthorized. + return false, nil + } + + principal, ok := principalForAccessContext(accessContext) + if !ok { + // An unknown or empty access scope authorizes nothing. + return false, nil + } + + // Grants are made against the ORIGINAL; a server-derived rendition inherits + // its original's grants. + resourceID := record.ID + if record.ParentID != nil { + resourceID = record.ParentID + } + + granted, err := s.access.HasGrant(ctx, resourceID, principal, PermissionRead) + if err != nil { + return false, err + } + if !granted { + return false, nil + } + return s.resolver.Covers(ctx, principal, caller) +} + +// principalForAccessContext maps a request's access context to the principal a +// grant for that surface is made to. It returns ok=false for an unknown or empty +// scope, which the caller treats as authorizing nothing. +func principalForAccessContext(accessContext *blobpb.AccessContext) (Principal, bool) { + switch scope := accessContext.GetScope().(type) { + case *blobpb.AccessContext_Chat: + return PrincipalForChat(scope.Chat), true + default: + return Principal{}, false + } +} + // finalize drives a blob through its processing pipeline, resuming from whatever // state it is already in: confirm the upload landed, validate + derive metadata + // moderate, copy into the origin store, and clean up. Each step checkpoints its diff --git a/blob/tests/access.go b/blob/tests/access.go new file mode 100644 index 0000000..bacc980 --- /dev/null +++ b/blob/tests/access.go @@ -0,0 +1,133 @@ +package tests + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + commonpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/common/v1" + + "github.com/code-payments/flipcash2-server/blob" + "github.com/code-payments/flipcash2-server/model" +) + +// RunAccessStoreTests runs the shared blob.AccessStore test suite. +func RunAccessStoreTests(t *testing.T, store blob.AccessStore, teardown func()) { + for _, tf := range []func(t *testing.T, store blob.AccessStore){ + testAccessGrantHasRevoke, + testAccessNoCollision, + testAccessValidation, + } { + tf(t, store) + teardown() + } +} + +func newChatID(t *testing.T) *commonpb.ChatId { + // A chat id is 32 bytes; two random UUIDs concatenated stand in for one. + a, err := uuid.NewRandom() + require.NoError(t, err) + b, err := uuid.NewRandom() + require.NoError(t, err) + value := append(append([]byte{}, a[:]...), b[:]...) + return &commonpb.ChatId{Value: value} +} + +func testAccessGrantHasRevoke(t *testing.T, store blob.AccessStore) { + ctx := context.Background() + + blobID := newBlobID(t) + chat := blob.PrincipalForChat(newChatID(t)) + + // Absent before granting. + has, err := store.HasGrant(ctx, blobID, chat, blob.PermissionRead) + require.NoError(t, err) + require.False(t, has) + + require.NoError(t, store.Grant(ctx, &blob.Grant{BlobID: blobID, Principal: chat, Permission: blob.PermissionRead})) + + has, err = store.HasGrant(ctx, blobID, chat, blob.PermissionRead) + require.NoError(t, err) + require.True(t, has) + + // Granting again is an idempotent no-op, not an error. + require.NoError(t, store.Grant(ctx, &blob.Grant{BlobID: blobID, Principal: chat, Permission: blob.PermissionRead})) + has, err = store.HasGrant(ctx, blobID, chat, blob.PermissionRead) + require.NoError(t, err) + require.True(t, has) + + // Revoke removes it; revoking again is also an idempotent no-op. + require.NoError(t, store.Revoke(ctx, blobID, chat, blob.PermissionRead)) + has, err = store.HasGrant(ctx, blobID, chat, blob.PermissionRead) + require.NoError(t, err) + require.False(t, has) + require.NoError(t, store.Revoke(ctx, blobID, chat, blob.PermissionRead)) +} + +func testAccessNoCollision(t *testing.T, store blob.AccessStore) { + ctx := context.Background() + + blobA := newBlobID(t) + blobB := newBlobID(t) + chat := blob.PrincipalForChat(newChatID(t)) + user := blob.PrincipalForUser(model.MustGenerateUserID()) + + require.NoError(t, store.Grant(ctx, &blob.Grant{BlobID: blobA, Principal: chat, Permission: blob.PermissionRead})) + + // The same grant on a different blob is absent. + has, err := store.HasGrant(ctx, blobB, chat, blob.PermissionRead) + require.NoError(t, err) + require.False(t, has) + + // The same blob and permission but a different principal is absent. + has, err = store.HasGrant(ctx, blobA, user, blob.PermissionRead) + require.NoError(t, err) + require.False(t, has) + + // The granted triple is present. + has, err = store.HasGrant(ctx, blobA, chat, blob.PermissionRead) + require.NoError(t, err) + require.True(t, has) + + // The principal type is part of the key: a user and a chat with identical id + // bytes are distinct grants and must not collide. + raw := newChatID(t).Value + asUser := blob.Principal{Type: blob.PrincipalTypeUser, ID: raw} + asChat := blob.Principal{Type: blob.PrincipalTypeChat, ID: raw} + require.NoError(t, store.Grant(ctx, &blob.Grant{BlobID: blobA, Principal: asUser, Permission: blob.PermissionRead})) + has, err = store.HasGrant(ctx, blobA, asChat, blob.PermissionRead) + require.NoError(t, err) + require.False(t, has) + has, err = store.HasGrant(ctx, blobA, asUser, blob.PermissionRead) + require.NoError(t, err) + require.True(t, has) +} + +func testAccessValidation(t *testing.T, store blob.AccessStore) { + ctx := context.Background() + + blobID := newBlobID(t) + chat := blob.PrincipalForChat(newChatID(t)) + + // An unknown permission is rejected, not silently stored. + err := store.Grant(ctx, &blob.Grant{BlobID: blobID, Principal: chat, Permission: blob.PermissionUnknown}) + require.ErrorIs(t, err, blob.ErrInvalidGrant) + + // An unknown principal type is rejected. + err = store.Grant(ctx, &blob.Grant{BlobID: blobID, Principal: blob.Principal{Type: blob.PrincipalTypeUnknown, ID: []byte{1}}, Permission: blob.PermissionRead}) + require.ErrorIs(t, err, blob.ErrInvalidGrant) + + // An empty principal id is rejected. + err = store.Grant(ctx, &blob.Grant{BlobID: blobID, Principal: blob.Principal{Type: blob.PrincipalTypeChat}, Permission: blob.PermissionRead}) + require.ErrorIs(t, err, blob.ErrInvalidGrant) + + // A missing blob id is rejected. + err = store.Grant(ctx, &blob.Grant{BlobID: nil, Principal: chat, Permission: blob.PermissionRead}) + require.ErrorIs(t, err, blob.ErrInvalidGrant) + + // HasGrant validates its lookup key too. + _, err = store.HasGrant(ctx, blobID, blob.Principal{Type: blob.PrincipalTypeUnknown, ID: []byte{1}}, blob.PermissionRead) + require.ErrorIs(t, err, blob.ErrInvalidGrant) +} diff --git a/blob/tests/server.go b/blob/tests/server.go index 5303a8c..8efd046 100644 --- a/blob/tests/server.go +++ b/blob/tests/server.go @@ -3,6 +3,7 @@ package tests import ( "bytes" "context" + "fmt" "image" "image/color" "image/png" @@ -39,10 +40,11 @@ func RunServerTests( accounts account.Store, blobs blob.Store, storage blob.ObjectStorage, + access blob.AccessStore, upload uploadFunc, teardown func(), ) { - for _, tf := range []func(t *testing.T, accounts account.Store, blobs blob.Store, storage blob.ObjectStorage, upload uploadFunc){ + for _, tf := range []func(t *testing.T, accounts account.Store, blobs blob.Store, storage blob.ObjectStorage, access blob.AccessStore, resolver *fakeResolver, upload uploadFunc){ testGetUploadPolicy, testInitiateExternalUpload, testUploadLifecycle, @@ -50,16 +52,18 @@ func RunServerTests( testModeration, testGetBlobs, } { - tf(t, accounts, blobs, storage, upload) + // A fresh resolver per test func; the access store is reset by teardown. + resolver := newFakeResolver() + tf(t, accounts, blobs, storage, access, resolver, upload) teardown() } } -func newServer(accounts account.Store, blobs blob.Store, storage blob.ObjectStorage, moderator moderation.Client, t *testing.T) *blob.Server { +func newServer(accounts account.Store, blobs blob.Store, storage blob.ObjectStorage, access blob.AccessStore, resolver blob.PrincipalResolver, moderator moderation.Client, t *testing.T) *blob.Server { log := zaptest.NewLogger(t) authn := auth.NewKeyPairAuthenticator(log) authz := account.NewAuthorizer(log, accounts, authn) - return blob.NewServer(log, authz, accounts, blobs, storage, moderator, false) + return blob.NewServer(log, authz, accounts, blobs, storage, access, resolver, moderator, false) } // registerUser binds a fresh key pair to a new, registered account. @@ -72,8 +76,8 @@ func registerUser(t *testing.T, accounts account.Store) (*commonpb.UserId, model return userID, signer } -func testGetUploadPolicy(t *testing.T, accounts account.Store, blobs blob.Store, storage blob.ObjectStorage, _ uploadFunc) { - server := newServer(accounts, blobs, storage, nil, t) +func testGetUploadPolicy(t *testing.T, accounts account.Store, blobs blob.Store, storage blob.ObjectStorage, access blob.AccessStore, resolver *fakeResolver, _ uploadFunc) { + server := newServer(accounts, blobs, storage, access, resolver, nil, t) t.Run("unregistered is denied", func(t *testing.T) { signer := model.MustGenerateKeyPair() @@ -142,8 +146,8 @@ func testGetUploadPolicy(t *testing.T, accounts account.Store, blobs blob.Store, }) } -func testInitiateExternalUpload(t *testing.T, accounts account.Store, blobs blob.Store, storage blob.ObjectStorage, _ uploadFunc) { - server := newServer(accounts, blobs, storage, nil, t) +func testInitiateExternalUpload(t *testing.T, accounts account.Store, blobs blob.Store, storage blob.ObjectStorage, access blob.AccessStore, resolver *fakeResolver, _ uploadFunc) { + server := newServer(accounts, blobs, storage, access, resolver, nil, t) imageBytes := makePNG(t, 8, 8) t.Run("unregistered is denied", func(t *testing.T) { @@ -211,8 +215,8 @@ func testInitiateExternalUpload(t *testing.T, accounts account.Store, blobs blob }) } -func testUploadLifecycle(t *testing.T, accounts account.Store, blobs blob.Store, storage blob.ObjectStorage, upload uploadFunc) { - server := newServer(accounts, blobs, storage, nil, t) +func testUploadLifecycle(t *testing.T, accounts account.Store, blobs blob.Store, storage blob.ObjectStorage, access blob.AccessStore, resolver *fakeResolver, upload uploadFunc) { + server := newServer(accounts, blobs, storage, access, resolver, nil, t) _, signer := registerUser(t, accounts) imageBytes := makePNG(t, 12, 9) @@ -274,8 +278,8 @@ func testUploadLifecycle(t *testing.T, accounts account.Store, blobs blob.Store, }) } -func testFinalizationRejections(t *testing.T, accounts account.Store, blobs blob.Store, storage blob.ObjectStorage, upload uploadFunc) { - server := newServer(accounts, blobs, storage, nil, t) +func testFinalizationRejections(t *testing.T, accounts account.Store, blobs blob.Store, storage blob.ObjectStorage, access blob.AccessStore, resolver *fakeResolver, upload uploadFunc) { + server := newServer(accounts, blobs, storage, access, resolver, nil, t) _, signer := registerUser(t, accounts) t.Run("non-image bytes are rejected as corrupt", func(t *testing.T) { @@ -305,12 +309,12 @@ func testFinalizationRejections(t *testing.T, accounts account.Store, blobs blob }) } -func testModeration(t *testing.T, accounts account.Store, blobs blob.Store, storage blob.ObjectStorage, upload uploadFunc) { +func testModeration(t *testing.T, accounts account.Store, blobs blob.Store, storage blob.ObjectStorage, access blob.AccessStore, resolver *fakeResolver, upload uploadFunc) { _, signer := registerUser(t, accounts) imageBytes := makePNG(t, 6, 6) t.Run("flagged image is rejected with the moderation category", func(t *testing.T) { - server := newServer(accounts, blobs, storage, &fakeModerator{flagged: true, categories: []string{"general_nsfw"}}, t) + server := newServer(accounts, blobs, storage, access, resolver, &fakeModerator{flagged: true, categories: []string{"general_nsfw"}}, t) blobID, target := initiate(t, server, signer, "image/png", uint64(len(imageBytes))) upload(target, imageBytes) @@ -322,7 +326,7 @@ func testModeration(t *testing.T, accounts account.Store, blobs blob.Store, stor }) t.Run("clean image is ready", func(t *testing.T) { - server := newServer(accounts, blobs, storage, &fakeModerator{flagged: false}, t) + server := newServer(accounts, blobs, storage, access, resolver, &fakeModerator{flagged: false}, t) blobID, target := initiate(t, server, signer, "image/png", uint64(len(imageBytes))) upload(target, imageBytes) @@ -330,8 +334,8 @@ func testModeration(t *testing.T, accounts account.Store, blobs blob.Store, stor }) } -func testGetBlobs(t *testing.T, accounts account.Store, blobs blob.Store, storage blob.ObjectStorage, upload uploadFunc) { - server := newServer(accounts, blobs, storage, nil, t) +func testGetBlobs(t *testing.T, accounts account.Store, blobs blob.Store, storage blob.ObjectStorage, access blob.AccessStore, resolver *fakeResolver, upload uploadFunc) { + server := newServer(accounts, blobs, storage, access, resolver, nil, t) _, signer := registerUser(t, accounts) imageBytes := makePNG(t, 10, 5) @@ -374,10 +378,10 @@ func testGetBlobs(t *testing.T, accounts account.Store, blobs blob.Store, storag require.EqualValues(t, 5, image.Height) }) - t.Run("a non-owner cannot resolve someone else's blob", func(t *testing.T) { - // Access is scoped to the uploader: a non-owner holding the id sees it as if - // it did not exist, so the batch comes back unset rather than leaking the - // blob's existence. + t.Run("a non-owner with no context cannot resolve someone else's blob", func(t *testing.T) { + // Without an access context a non-owner holding the id sees it as if it did + // not exist, so the batch comes back unset rather than leaking the blob's + // existence. _, other := registerUser(t, accounts) req := &blobpb.GetBlobsRequest{BlobIds: &blobpb.BlobIdBatch{BlobIds: []*blobpb.BlobId{readyID}}} require.NoError(t, other.Auth(req, &req.Auth)) @@ -388,6 +392,68 @@ func testGetBlobs(t *testing.T, accounts account.Store, blobs blob.Store, storag require.Nil(t, resp.Blobs) }) + t.Run("a non-owner resolves a blob shared into a chat they belong to", func(t *testing.T) { + otherID, other := registerUser(t, accounts) + chatID := newChatID(t) + // The blob is shared into the chat, and the caller is a member of it. + require.NoError(t, access.Grant(context.Background(), &blob.Grant{ + BlobID: readyID, Principal: blob.PrincipalForChat(chatID), Permission: blob.PermissionRead, + })) + resolver.allow(blob.PrincipalForChat(chatID), otherID) + + req := &blobpb.GetBlobsRequest{ + BlobIds: &blobpb.BlobIdBatch{BlobIds: []*blobpb.BlobId{readyID}}, + Context: &blobpb.AccessContext{Scope: &blobpb.AccessContext_Chat{Chat: chatID}}, + } + require.NoError(t, other.Auth(req, &req.Auth)) + + resp, err := server.GetBlobs(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp.Blobs) + require.Len(t, resp.Blobs.Blobs, 1) + require.Equal(t, blobpb.BlobStatus_BLOB_STATUS_READY, resp.Blobs.Blobs[0].Status) + require.NotNil(t, resp.Blobs.Blobs[0].Metadata) + }) + + t.Run("a member of the chat cannot resolve a blob never shared into it", func(t *testing.T) { + // The caller belongs to the chat, but the blob carries no grant for it, so + // membership alone does not authorize the read. + otherID, other := registerUser(t, accounts) + chatID := newChatID(t) + resolver.allow(blob.PrincipalForChat(chatID), otherID) + + req := &blobpb.GetBlobsRequest{ + BlobIds: &blobpb.BlobIdBatch{BlobIds: []*blobpb.BlobId{readyID}}, + Context: &blobpb.AccessContext{Scope: &blobpb.AccessContext_Chat{Chat: chatID}}, + } + require.NoError(t, other.Auth(req, &req.Auth)) + + resp, err := server.GetBlobs(context.Background(), req) + require.NoError(t, err) + require.Nil(t, resp.Blobs) + }) + + t.Run("a grant does not help a caller who is not in the chat", func(t *testing.T) { + // The blob is shared into the chat, but the caller is not a member (the + // resolver was never told to cover them), so the grant alone does not + // authorize the read. + _, other := registerUser(t, accounts) + chatID := newChatID(t) + require.NoError(t, access.Grant(context.Background(), &blob.Grant{ + BlobID: readyID, Principal: blob.PrincipalForChat(chatID), Permission: blob.PermissionRead, + })) + + req := &blobpb.GetBlobsRequest{ + BlobIds: &blobpb.BlobIdBatch{BlobIds: []*blobpb.BlobId{readyID}}, + Context: &blobpb.AccessContext{Scope: &blobpb.AccessContext_Chat{Chat: chatID}}, + } + require.NoError(t, other.Auth(req, &req.Auth)) + + resp, err := server.GetBlobs(context.Background(), req) + require.NoError(t, err) + require.Nil(t, resp.Blobs) + }) + t.Run("owner resolves a rejected blob with rejection metadata", func(t *testing.T) { req := &blobpb.GetBlobsRequest{BlobIds: &blobpb.BlobIdBatch{BlobIds: []*blobpb.BlobId{rejectedID}}} require.NoError(t, signer.Auth(req, &req.Auth)) @@ -483,6 +549,28 @@ func makePNG(t *testing.T, width, height int) []byte { return buf.Bytes() } +// fakeResolver is a controllable blob.PrincipalResolver for the server suite: a +// (principal, user) pair resolves as covered only after allow records it. +type fakeResolver struct { + covered map[string]bool +} + +func newFakeResolver() *fakeResolver { + return &fakeResolver{covered: make(map[string]bool)} +} + +func (r *fakeResolver) allow(principal blob.Principal, user *commonpb.UserId) { + r.covered[resolverKey(principal, user)] = true +} + +func (r *fakeResolver) Covers(_ context.Context, principal blob.Principal, user *commonpb.UserId) (bool, error) { + return r.covered[resolverKey(principal, user)], nil +} + +func resolverKey(principal blob.Principal, user *commonpb.UserId) string { + return fmt.Sprintf("%d|%q|%q", int(principal.Type), principal.ID, user.Value) +} + type fakeModerator struct { flagged bool // categories, when set, are returned as the flagged categories (each given a diff --git a/go.mod b/go.mod index 527d229..2aaf591 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.42.3 github.com/aws/smithy-go v1.27.1 github.com/buckket/go-blurhash v1.1.0 - github.com/code-payments/flipcash2-protobuf-api v1.15.0 + github.com/code-payments/flipcash2-protobuf-api v1.15.1-0.20260630154709-688f0238c503 github.com/code-payments/ocp-protobuf-api v1.13.2-0.20260610171241-de46af911053 github.com/code-payments/ocp-server v1.19.0 github.com/devsisters/go-applereceipt v0.0.0-20240805020915-fa22a0160fc2 diff --git a/go.sum b/go.sum index 43c8889..b9e896f 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,8 @@ github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB0 github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI= github.com/code-payments/code-vm-indexer v1.2.0 h1:rSHpBMiT9BKgmKcXg/VIoi/h0t7jNxGx07Qz59m+6Q0= github.com/code-payments/code-vm-indexer v1.2.0/go.mod h1:vn91YN2qNqb+gGJeZe2+l+TNxVmEEiRHXXnIn2Y40h8= -github.com/code-payments/flipcash2-protobuf-api v1.15.0 h1:xVjrxqgnxCxdZQ0I/SGjtso2z6E9ZWCxVv/h5E+3/Ps= -github.com/code-payments/flipcash2-protobuf-api v1.15.0/go.mod h1:s/1pOsb4FTRD+LcvRKGjfmm6ygRS/m1ep34EIW0fuDs= +github.com/code-payments/flipcash2-protobuf-api v1.15.1-0.20260630154709-688f0238c503 h1:pvLLHzvlcphhVmDDb9GYsTYloW39uMbMH4V4yASEqvU= +github.com/code-payments/flipcash2-protobuf-api v1.15.1-0.20260630154709-688f0238c503/go.mod h1:s/1pOsb4FTRD+LcvRKGjfmm6ygRS/m1ep34EIW0fuDs= github.com/code-payments/ocp-protobuf-api v1.13.2-0.20260610171241-de46af911053 h1:0B96K7/z7TpFbAJeyzP43A0eM58Ie/KRuu5U6PUrses= github.com/code-payments/ocp-protobuf-api v1.13.2-0.20260610171241-de46af911053/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= github.com/code-payments/ocp-server v1.19.0 h1:l4cznUxOAmaclHKpJANJ2zE3LTXg+BDBbRhl+r66lFI=