Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions blob/access.go
Original file line number Diff line number Diff line change
@@ -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
}
}
108 changes: 108 additions & 0 deletions blob/access_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading