feat(authguest): upd renewal logic to verify token and session, storage has now revisions

This commit is contained in:
Alex Ababii committed 2026-10-13 09:30:52 +02:00
1 parent fb09fedc20
commit b266546a45
16 files changed
+657 -97

No files matched your search

+31 -27
View File
@@ -75,24 +75,23 @@ sequenceDiagram
A guest session (signed JWT) is valid for 24 hours by default
(`AUTH_GUEST_JWT_TTL`); a link token is single-use and valid for 30 minutes.
Renewal starts from a *failed* attempt`:
Renewal starts from a *failed* attempt:
- an authenticated request made with an expired session is rejected with `401`;
the browser still holds the (expired) session cookie;
- `redeem` on a link that was already used answers `409`, and on an expired
link `401`.
link `401`; the client still holds the link token.
Both errors carry the share id, which the client sends to `renew` as
`permissionId`.
`renew` itself does not create a
session. It requires an existing token record for the share
, validates the share, generates a new link token and a 6-digit PIN (both valid
for 30 minutes), overwrites the stored token and PIN hashes (invalidating the
previous link and PIN), resets the redeemed flag so the new link can be
redeemed, and publishes `GuestTokenRenewed`. It answers `200`. This works
whether the record is unused, expired or already redeemed (the guest already
redeemed an earlier link); if publishing fails, the previous record is
restored.
`renew` requires the share id (`permissionId`) and is authorized by either the
(possibly expired) session cookie or the previous link token, whichever the
client has; the credential must belong to the given share. It does not create a
session. It requires an existing token record for the share, validates the
share, generates a new link token and a 6-digit PIN (both valid for 30 minutes),
overwrites the stored token and PIN hashes (invalidating the previous link and
PIN), resets the redeemed flag so the new link can be redeemed, and publishes
`GuestTokenRenewed`. It answers `200`. This works whether the record is unused,
expired or already redeemed (the guest already redeemed an earlier link); if
publishing fails, the previous record is restored.
To turn the new credentials into a session the guest either redeems the link
(`redeem`) or enters the PIN (`verify/pin`); both return a session cookie and
@@ -109,7 +108,7 @@ sequenceDiagram
participant Bus as Event bus
Note over Web: Session expired / previous link used or expired
Web->>+Renew: POST renew { permissionId }
Web->>+Renew: POST renew { permissionId } (+ session cookie or link token)
Note right of Renew: Validate share, generate token + PIN, store hashes
Renew->>Bus: publish GuestTokenRenewed
Note right of Bus: External consumer delivers token + PIN (out of scope)
@@ -144,14 +143,16 @@ sequenceDiagram
30 minutes; the session cookie lives for 24 hours by default
(`AUTH_GUEST_JWT_TTL`).
3. **Renew** — the guest posts the `permissionId` to
`POST /graph/v1beta1/extensions/org.libregraph/guestLinks/renew`.
An existing record is required; otherwise the request fails with
`tokenNotFound`. The service validates the share, generates a new link token
and a 6-digit PIN (both valid for 30 minutes), overwrites the stored token
and PIN hashes (invalidating the previous link and PIN) and resets the
redeemed flag so the new link can be redeemed, then publishes the
`GuestTokenRenewed` event. This works whether the record is unused, expired
or already redeemed. If publishing fails, the previous record is restored.
`POST /graph/v1beta1/extensions/org.libregraph/guestLinks/renew`, authorized
by either the previous link token (`{ "token": "<token>" }`) or the (possibly
expired) session cookie, which must both belong to that share. An existing
record is required; otherwise the request fails with `tokenNotFound`. The
service validates the share, generates a new link token and a 6-digit PIN
(both valid for 30 minutes), overwrites the stored token and PIN hashes
(invalidating the previous link and PIN) and resets the redeemed flag so the
new link can be redeemed, then publishes the `GuestTokenRenewed` event. This
works whether the record is unused, expired or already redeemed. If
publishing fails, the previous record is restored.
4. **Verify PIN** — the guest posts the PIN and the `permissionId` to
`POST /graph/v1beta1/extensions/org.libregraph/guestLinks/verify/pin`.
The service validates the record and the share, compares the PIN against the
@@ -177,12 +178,15 @@ Alias: `.../guestLinks/verify/token`
#### `POST .../guestLinks/renew`
- **Request body:** `{ "permissionId": "<share-id>" }`
- **Request body:** `{ "permissionId": "<share-id>", "token": "<previous link token>" }`
(`token` optional when the session cookie is sent)
- **Authorization:** the share id is required; the body link token or the
session cookie must belong to it, the link token takes precedence
- **Success:** `200` (no session cookie; the new link/PIN are delivered out
of band)
- **Errors:** `400 invalidRequest`, `404 tokenNotFound`,
`404 shareNotFound`, `410 shareExpired`, `503 serviceUnavailable`,
`500 internalError`
- **Errors:** `400 invalidRequest`, `401 tokenInvalid`, `401 sessionInvalid`,
`404 tokenNotFound`, `404 shareNotFound`, `410 shareExpired`,
`409 tokenAlreadyRedeemed`, `503 serviceUnavailable`, `500 internalError`
#### `POST .../guestLinks/verify/pin`
@@ -9,6 +9,7 @@ import (
"net/http"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/jwt"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/token"
)
@@ -58,6 +59,10 @@ func writeGuestError(w http.ResponseWriter, err error) {
status, errorType = http.StatusUnauthorized, "pinInvalid"
case errors.Is(ge.ErrorType, authguest.ErrPinExpired):
status, errorType = http.StatusUnauthorized, "pinExpired"
case errors.Is(ge.ErrorType, jwt.ErrInvalidSession):
status, errorType = http.StatusUnauthorized, "sessionInvalid"
case errors.Is(ge.ErrorType, authguest.ErrPermissionMismatch):
status, errorType = http.StatusBadRequest, "invalidRequest"
}
message := ge.ErrorType.Error()
+15 -4
View File
@@ -8,16 +8,22 @@ import (
"net/http"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest"
)
// RenewRequest is the request body for token renewal.
// RenewRequest is the request body for token renewal. The share id is required;
// the previous link token is optional when the (possibly expired) session
// cookie is sent instead.
type RenewRequest struct {
PermissionID string `json:"permissionId"`
Token string `json:"token"`
}
// RenewHandler generates a new guest link token for the submitted share.
func RenewHandler(log log.Logger, s authguest.AuthGuest) func(w http.ResponseWriter, r *http.Request) {
// RenewHandler generates a new guest link token and PIN for the share
// identified by the permission id, authorized by the submitted link token or
// the session cookie.
func RenewHandler(log log.Logger, s authguest.AuthGuest, cfg *config.Config) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var req RenewRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -32,7 +38,12 @@ func RenewHandler(log log.Logger, s authguest.AuthGuest) func(w http.ResponseWri
return
}
if err := s.Renew(r.Context(), req.PermissionID); err != nil {
sessionToken := ""
if cookie, err := r.Cookie(cfg.JWT.CookieName); err == nil {
sessionToken = cookie.Value
}
if err := s.Renew(r.Context(), req.PermissionID, req.Token, sessionToken); err != nil {
log.Debug().Err(err).Msg("renew failed")
writeGuestError(w, err)
return
@@ -10,32 +10,60 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest/mocks"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/jwt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
const testCookieName = "__Host-oc_guest_session"
func newRenewHandler(t *testing.T, svc authguest.AuthGuest) http.HandlerFunc {
t.Helper()
return RenewHandler(log.NopLogger(), svc)
cfg := &config.Config{
JWT: config.JWT{
CookieName: testCookieName,
TTL: time.Hour,
},
}
return RenewHandler(log.NopLogger(), svc, cfg)
}
func TestRenewHandler(t *testing.T) {
func TestRenewHandlerWithLinkToken(t *testing.T) {
svcMock := mocks.NewAuthGuest(t)
svcMock.On("Renew", mock.Anything, "share-1").Return(nil)
svcMock.On("Renew", mock.Anything, "share-1", "link-token", "").Return(nil)
body, err := json.Marshal(RenewRequest{PermissionID: "share-1"})
body, err := json.Marshal(RenewRequest{PermissionID: "share-1", Token: "link-token"})
require.NoError(t, err)
rr := httptest.NewRecorder()
newRenewHandler(t, svcMock)(rr, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body))))
assert.Equal(t, http.StatusOK, rr.Code)
svcMock.AssertCalled(t, "Renew", mock.Anything, "share-1")
svcMock.AssertCalled(t, "Renew", mock.Anything, "share-1", "link-token", "")
}
func TestRenewHandlerWithSessionCookie(t *testing.T) {
svcMock := mocks.NewAuthGuest(t)
svcMock.On("Renew", mock.Anything, "share-1", "", "session-token").Return(nil)
body, err := json.Marshal(RenewRequest{PermissionID: "share-1"})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
req.AddCookie(&http.Cookie{Name: testCookieName, Value: "session-token"})
rr := httptest.NewRecorder()
newRenewHandler(t, svcMock)(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
svcMock.AssertCalled(t, "Renew", mock.Anything, "share-1", "", "session-token")
}
func TestRenewHandlerErrorMapping(t *testing.T) {
@@ -60,6 +88,20 @@ func TestRenewHandlerErrorMapping(t *testing.T) {
wantType: "shareExpired",
wantPermission: "share-1",
},
{
name: "permission mismatch",
err: &authguest.GuestError{ErrorType: authguest.ErrPermissionMismatch, ShareID: "share-1"},
wantStatus: http.StatusBadRequest,
wantType: "invalidRequest",
wantPermission: "share-1",
},
{
name: "invalid session",
err: &authguest.GuestError{ErrorType: jwt.ErrInvalidSession, ShareID: "share-1"},
wantStatus: http.StatusUnauthorized,
wantType: "sessionInvalid",
wantPermission: "share-1",
},
{
name: "internal error",
err: errors.New("boom"),
@@ -77,9 +119,9 @@ func TestRenewHandlerErrorMapping(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svcMock := mocks.NewAuthGuest(t)
svcMock.On("Renew", mock.Anything, "share-1").Return(tt.err)
svcMock.On("Renew", mock.Anything, "share-1", "link-token", "").Return(tt.err)
body, err := json.Marshal(RenewRequest{PermissionID: "share-1"})
body, err := json.Marshal(RenewRequest{PermissionID: "share-1", Token: "link-token"})
require.NoError(t, err)
rr := httptest.NewRecorder()
@@ -111,7 +153,7 @@ func TestRenewHandlerMalformedBody(t *testing.T) {
func TestRenewHandlerMissingPermissionID(t *testing.T) {
svcMock := mocks.NewAuthGuest(t)
body, err := json.Marshal(RenewRequest{})
body, err := json.Marshal(RenewRequest{Token: "link-token"})
require.NoError(t, err)
rr := httptest.NewRecorder()
@@ -61,7 +61,7 @@ func Server(opts ...Option) (ohttp.Service, error) {
mux.Route(options.Config.HTTP.Root, func(r chi.Router) {
r.Post("/v1beta1/extensions/org.libregraph/guestLinks/redeem", RedeemHandler(options.Logger, options.Service, options.Config))
r.Post("/v1beta1/extensions/org.libregraph/guestLinks/verify/token", RedeemHandler(options.Logger, options.Service, options.Config))
r.Post("/v1beta1/extensions/org.libregraph/guestLinks/renew", RenewHandler(options.Logger, options.Service))
r.Post("/v1beta1/extensions/org.libregraph/guestLinks/renew", RenewHandler(options.Logger, options.Service, options.Config))
r.Post("/v1beta1/extensions/org.libregraph/guestLinks/verify/pin", VerifyPinHandler(options.Logger, options.Service, options.Config))
})
@@ -227,16 +227,16 @@ func (_c *AuthGuest_Redeem_Call) RunAndReturn(run func(ctx context.Context, toke
}
// Renew provides a mock function for the type AuthGuest
func (_mock *AuthGuest) Renew(ctx context.Context, shareID string) error {
ret := _mock.Called(ctx, shareID)
func (_mock *AuthGuest) Renew(ctx context.Context, shareID string, tokenString string, sessionToken string) error {
ret := _mock.Called(ctx, shareID, tokenString, sessionToken)
if len(ret) == 0 {
panic("no return value specified for Renew")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
r0 = returnFunc(ctx, shareID)
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok {
r0 = returnFunc(ctx, shareID, tokenString, sessionToken)
} else {
r0 = ret.Error(0)
}
@@ -251,11 +251,13 @@ type AuthGuest_Renew_Call struct {
// Renew is a helper method to define mock.On call
// - ctx context.Context
// - shareID string
func (_e *AuthGuest_Expecter) Renew(ctx any, shareID any) *AuthGuest_Renew_Call {
return &AuthGuest_Renew_Call{Call: _e.mock.On("Renew", ctx, shareID)}
// - tokenString string
// - sessionToken string
func (_e *AuthGuest_Expecter) Renew(ctx any, shareID any, tokenString any, sessionToken any) *AuthGuest_Renew_Call {
return &AuthGuest_Renew_Call{Call: _e.mock.On("Renew", ctx, shareID, tokenString, sessionToken)}
}
func (_c *AuthGuest_Renew_Call) Run(run func(ctx context.Context, shareID string)) *AuthGuest_Renew_Call {
func (_c *AuthGuest_Renew_Call) Run(run func(ctx context.Context, shareID string, tokenString string, sessionToken string)) *AuthGuest_Renew_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
@@ -265,9 +267,19 @@ func (_c *AuthGuest_Renew_Call) Run(run func(ctx context.Context, shareID string
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 string
if args[2] != nil {
arg2 = args[2].(string)
}
var arg3 string
if args[3] != nil {
arg3 = args[3].(string)
}
run(
arg0,
arg1,
arg2,
arg3,
)
})
return _c
@@ -278,7 +290,7 @@ func (_c *AuthGuest_Renew_Call) Return(err error) *AuthGuest_Renew_Call {
return _c
}
func (_c *AuthGuest_Renew_Call) RunAndReturn(run func(ctx context.Context, shareID string) error) *AuthGuest_Renew_Call {
func (_c *AuthGuest_Renew_Call) RunAndReturn(run func(ctx context.Context, shareID string, tokenString string, sessionToken string) error) *AuthGuest_Renew_Call {
_c.Call.Return(run)
return _c
}
@@ -31,6 +31,7 @@ var ErrShareExpired = errors.New("share expired")
var ErrEventsNotConfigured = errors.New("event publisher not configured")
var ErrPinInvalid = errors.New("pin invalid")
var ErrPinExpired = errors.New("pin expired")
var ErrPermissionMismatch = errors.New("permissionId does not match the provided credentials")
const guestLinkTokenTTL = 30 * time.Minute
const guestPinTTL = 30 * time.Minute
@@ -55,7 +56,7 @@ type SessionResponse struct {
type AuthGuest interface {
CreateToken(ctx context.Context, shareID string) (*token.Token, error)
Redeem(ctx context.Context, tokenString string) (*SessionResponse, error)
Renew(ctx context.Context, shareID string) error
Renew(ctx context.Context, shareID, tokenString, sessionToken string) error
VerifyPin(ctx context.Context, shareID, pinValue string) (*SessionResponse, error)
CleanupShare(shareID string) error
}
@@ -119,13 +120,16 @@ func (s *AuthGuestService) Redeem(ctx context.Context, tokenString string) (*Ses
return nil, err
}
if err := s.store.Update(rec.ShareIDHash, func(r *storage.Record) error {
if _, err := s.store.UpdateFrom(*rec, func(r *storage.Record) error {
if r.Redeemed {
return ErrAlreadyRedeemed
}
r.Redeemed = true
return nil
}); err != nil {
if errors.Is(err, storage.ErrConflict) {
err = ErrAlreadyRedeemed
}
return nil, &GuestError{ErrorType: err, ShareID: rec.ShareID}
}
@@ -137,12 +141,17 @@ func (s *AuthGuestService) Redeem(ctx context.Context, tokenString string) (*Ses
return &SessionResponse{SessionToken: sessionToken, ShareID: rec.ShareID}, nil
}
// Renew generates a new guest link token and PIN for the given share.
func (s *AuthGuestService) Renew(ctx context.Context, shareID string) error {
// Renew generates a new guest link token and PIN for shareID, authorized by the
// previous link token or a (possibly expired) session token.
func (s *AuthGuestService) Renew(ctx context.Context, shareID, tokenString, sessionToken string) error {
if s.publisher == nil {
return ErrEventsNotConfigured
}
if err := s.verifyRenewCredentials(shareID, tokenString, sessionToken); err != nil {
return err
}
share, err := s.validateShare(ctx, shareID)
if err != nil {
return err
@@ -165,16 +174,23 @@ func (s *AuthGuestService) Renew(ctx context.Context, shareID string) error {
now := time.Now()
var previous storage.Record
if err := s.store.Update(token.Hash(shareID), func(rec *storage.Record) error {
previous = *rec
current, err := s.store.Get(token.Hash(shareID))
if err != nil {
return &GuestError{ErrorType: err, ShareID: shareID}
}
updated, err := s.store.UpdateFrom(*current, func(rec *storage.Record) error {
rec.SecretHash = tok.SecretHash()
rec.PinHash = pinHash
rec.Expiry = now.Add(guestLinkTokenTTL)
rec.PinExpiry = now.Add(guestPinTTL)
rec.Redeemed = false
return nil
}); err != nil {
})
if err != nil {
if errors.Is(err, storage.ErrConflict) {
err = ErrAlreadyRedeemed
}
return &GuestError{ErrorType: err, ShareID: shareID}
}
@@ -187,8 +203,8 @@ func (s *AuthGuestService) Renew(ctx context.Context, shareID string) error {
Pin: pinStr,
Timestamp: now,
}); err != nil {
if rbErr := s.store.Update(token.Hash(shareID), func(rec *storage.Record) error {
*rec = previous
if _, rbErr := s.store.UpdateFrom(*updated, func(rec *storage.Record) error {
*rec = *current
return nil
}); rbErr != nil {
return fmt.Errorf("publishing guest token renewed event failed: %w (rollback failed: %v)", err, rbErr)
@@ -252,6 +268,53 @@ func (s *AuthGuestService) CleanupShare(shareID string) error {
return nil
}
// verifyRenewCredentials checks that the previous link token or a (possibly
// expired) session token belongs to shareID.
func (s *AuthGuestService) verifyRenewCredentials(shareID, tokenString, sessionToken string) error {
switch {
case tokenString != "":
return s.verifyRenewToken(shareID, tokenString)
case sessionToken != "":
if s.jwtService == nil {
return &GuestError{ErrorType: jwt.ErrInvalidSession, ShareID: shareID}
}
sessionShareID, err := s.jwtService.Verify(sessionToken)
if err != nil {
return &GuestError{ErrorType: err, ShareID: shareID}
}
if sessionShareID != shareID {
return &GuestError{ErrorType: ErrPermissionMismatch, ShareID: shareID}
}
return nil
default:
return &GuestError{ErrorType: jwt.ErrInvalidSession, ShareID: shareID}
}
}
// verifyRenewToken checks that the link token belongs to shareID and matches
// the stored record, ignoring its expiry and redeemed state.
func (s *AuthGuestService) verifyRenewToken(shareID, tokenString string) error {
tok, err := s.tokenSvc.Parse(tokenString)
if err != nil {
return &GuestError{ErrorType: err, ShareID: shareID}
}
if tok.ShareIDHash != token.Hash(shareID) {
return &GuestError{ErrorType: ErrPermissionMismatch, ShareID: shareID}
}
rec, err := s.store.Get(tok.ShareIDHash)
if err != nil {
return &GuestError{ErrorType: err, ShareID: shareID}
}
if err := s.tokenSvc.Verify(*tok, rec.SecretHash); err != nil {
return &GuestError{ErrorType: err, ShareID: shareID}
}
return nil
}
// VerifyToken validates a token and returns its stored record.
//
// Until the secret has been verified, all failures are reported as
@@ -280,7 +343,7 @@ func (s *AuthGuestService) verifyToken(tokenString string) (*storage.Record, err
return nil, &GuestError{ErrorType: ErrAlreadyRedeemed, ShareID: rec.ShareID}
}
return &rec, nil
return rec, nil
}
// validateShare extracts the share information from the gateway and checks its existence and expiration.
@@ -46,6 +46,41 @@ func (p *testPublisher) Publish(_ string, ev any, _ ...microevents.PublishOption
return p.err
}
// rewriteStore wraps a Manager and runs onGet once after the first Get, which
// lets tests simulate a concurrent modification between a read and a later
// write.
type rewriteStore struct {
storage.Manager
onGet func()
}
func (s *rewriteStore) Get(shareIDHash string) (*storage.Record, error) {
rec, err := s.Manager.Get(shareIDHash)
if s.onGet != nil {
onGet := s.onGet
s.onGet = nil
onGet()
}
return rec, err
}
// mutatingPublisher wraps testPublisher and runs onPublish once before
// publishing, which lets tests simulate a concurrent modification during a
// failed publish.
type mutatingPublisher struct {
testPublisher
onPublish func()
}
func (p *mutatingPublisher) Publish(name string, ev any, opts ...microevents.PublishOption) error {
if p.onPublish != nil {
onPublish := p.onPublish
p.onPublish = nil
onPublish()
}
return p.testPublisher.Publish(name, ev, opts...)
}
type gatewayTestSelector struct {
client gateway.GatewayAPIClient
}
@@ -99,6 +134,22 @@ func newToken(t *testing.T) (string, storage.Record) {
return tok.String(), rec
}
func newTokenFor(t *testing.T, shareID string) (string, storage.Record) {
t.Helper()
ts := token.NewTokenService()
tok, err := ts.Generate(shareID)
require.NoError(t, err)
rec := storage.Record{
ShareID: shareID,
ShareIDHash: tok.ShareIDHash,
SecretHash: tok.SecretHash(),
Expiry: time.Date(2026, 12, 31, 23, 59, 59, 0, time.UTC),
}
return tok.String(), rec
}
func newShareService(t *testing.T, gwc *cs3mocks.GatewayAPIClient) *AuthGuestService {
t.Helper()
return NewAuthGuestService(
@@ -177,7 +228,7 @@ func TestVerifyToken(t *testing.T) {
if tt.redeemed {
rec.Redeemed = true
}
store.On("Get", rec.ShareIDHash).Return(rec, nil)
store.On("Get", rec.ShareIDHash).Return(&rec, nil)
got, err := s.verifyToken(tok)
if tt.wantErr != nil {
@@ -291,6 +342,34 @@ func TestRedeemAlreadyRedeemed(t *testing.T) {
assert.ErrorIs(t, ge.ErrorType, ErrAlreadyRedeemed)
}
func TestRedeemConflictIsAlreadyRedeemed(t *testing.T) {
base := storage.NewFileManager(t.TempDir())
tok, rec := newToken(t)
require.NoError(t, base.Add(rec))
store := &rewriteStore{Manager: base}
store.onGet = func() {
require.NoError(t, base.Update(rec.ShareIDHash, func(r *storage.Record) error {
return nil
}))
}
share := &collaboration.Share{Id: &collaboration.ShareId{OpaqueId: testShareID}}
s := newRedeemService(t, store, newGatewayMock(&collaboration.GetShareResponse{
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
Share: share,
}))
_, err := s.Redeem(context.Background(), tok)
var ge *GuestError
require.ErrorAs(t, err, &ge)
assert.ErrorIs(t, ge.ErrorType, ErrAlreadyRedeemed)
got, err := base.Get(rec.ShareIDHash)
require.NoError(t, err)
assert.False(t, got.Redeemed)
}
func newRenewService(t *testing.T, store storage.Manager, gwc *cs3mocks.GatewayAPIClient, publisher revaevents.Publisher) *AuthGuestService {
t.Helper()
return NewAuthGuestService(
@@ -298,6 +377,7 @@ func newRenewService(t *testing.T, store storage.Manager, gwc *cs3mocks.GatewayA
store,
GatewaySelector(newGatewayTestSelector(gwc)),
ServiceAccount(config.ServiceAccount{ServiceAccountID: "sa-id", ServiceAccountSecret: "sa-secret"}),
JWT(jwt.NewJwtService("test-secret", 24*time.Hour)),
EventsPublisher(publisher),
)
}
@@ -311,7 +391,8 @@ func existingRecord() storage.Record {
func TestRenewPersistsRecordAndPublishesEvent(t *testing.T) {
store := storage.NewFileManager(t.TempDir())
require.NoError(t, store.Add(existingRecord()))
tok, rec := newToken(t)
require.NoError(t, store.Add(rec))
publisher := &testPublisher{}
gwc := newGatewayMock(&collaboration.GetShareResponse{
@@ -321,10 +402,11 @@ func TestRenewPersistsRecordAndPublishesEvent(t *testing.T) {
s := newRenewService(t, store, gwc, publisher)
require.NoError(t, s.Renew(context.Background(), testShareID))
require.NoError(t, s.Renew(context.Background(), testShareID, tok, ""))
replaced, err := store.Get(token.Hash(testShareID))
require.NoError(t, err)
assert.Equal(t, rec.Revision+1, replaced.Revision)
assert.Equal(t, testShareID, replaced.ShareID)
assert.Equal(t, token.Hash(testShareID), replaced.ShareIDHash)
assert.NotEmpty(t, replaced.SecretHash)
@@ -351,8 +433,82 @@ func TestRenewPersistsRecordAndPublishesEvent(t *testing.T) {
assert.True(t, pinOK)
}
func TestRenewWithExpiredSessionToken(t *testing.T) {
store := storage.NewFileManager(t.TempDir())
require.NoError(t, store.Add(existingRecord()))
jwtSvc := jwt.NewJwtService("test-secret", -time.Hour)
sessionToken, err := jwtSvc.Sign(testShareID)
require.NoError(t, err)
publisher := &testPublisher{}
s := NewAuthGuestService(
token.NewTokenService(),
store,
GatewaySelector(newGatewayTestSelector(newGatewayMock(&collaboration.GetShareResponse{
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
Share: newGuestShare(time.Now().Add(time.Hour)),
}))),
ServiceAccount(config.ServiceAccount{ServiceAccountID: "sa-id", ServiceAccountSecret: "sa-secret"}),
JWT(jwtSvc),
EventsPublisher(publisher),
)
require.NoError(t, s.Renew(context.Background(), testShareID, "", sessionToken))
require.Len(t, publisher.published, 1)
}
func TestRenewLinkTokenIgnoresRedeemedAndExpiry(t *testing.T) {
store := storage.NewFileManager(t.TempDir())
tok, rec := newToken(t)
rec.Redeemed = true
rec.Expiry = time.Now().Add(-time.Hour)
require.NoError(t, store.Add(rec))
s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
Share: newGuestShare(time.Now().Add(time.Hour)),
}), &testPublisher{})
require.NoError(t, s.Renew(context.Background(), testShareID, tok, ""))
}
func TestRenewInvalidCredentials(t *testing.T) {
store := storage.NewFileManager(t.TempDir())
s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{}), &testPublisher{})
otherSession, err := jwt.NewJwtService("test-secret", 24*time.Hour).Sign("other-share")
require.NoError(t, err)
otherToken, _ := newTokenFor(t, "other-share")
tests := []struct {
name string
shareID string
token string
sessionToken string
wantErr error
}{
{name: "no credentials", shareID: testShareID, wantErr: jwt.ErrInvalidSession},
{name: "invalid session", shareID: testShareID, sessionToken: "garbage", wantErr: jwt.ErrInvalidSession},
{name: "invalid link token prefers token", shareID: testShareID, token: "garbage", sessionToken: "garbage", wantErr: token.ErrInvalidToken},
{name: "session for another share", shareID: testShareID, sessionToken: otherSession, wantErr: ErrPermissionMismatch},
{name: "token for another share", shareID: testShareID, token: otherToken, wantErr: ErrPermissionMismatch},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := s.Renew(context.Background(), tt.shareID, tt.token, tt.sessionToken)
var ge *GuestError
require.ErrorAs(t, err, &ge)
assert.ErrorIs(t, ge.ErrorType, tt.wantErr)
})
}
}
func TestRenewRecordNotFound(t *testing.T) {
store := storage.NewFileManager(t.TempDir())
tok, _ := newToken(t)
publisher := &testPublisher{}
s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{
@@ -360,7 +516,7 @@ func TestRenewRecordNotFound(t *testing.T) {
Share: newGuestShare(time.Now().Add(time.Hour)),
}), publisher)
err := s.Renew(context.Background(), testShareID)
err := s.Renew(context.Background(), testShareID, tok, "")
require.Error(t, err)
var ge *GuestError
@@ -377,7 +533,7 @@ func TestRenewWithoutPublisher(t *testing.T) {
s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{}), nil)
err := s.Renew(context.Background(), testShareID)
err := s.Renew(context.Background(), testShareID, "irrelevant", "")
require.ErrorIs(t, err, ErrEventsNotConfigured)
got, err := store.Get(token.Hash(testShareID))
@@ -388,7 +544,8 @@ func TestRenewWithoutPublisher(t *testing.T) {
func TestRenewRollsBackOnPublishFailure(t *testing.T) {
store := storage.NewFileManager(t.TempDir())
require.NoError(t, store.Add(existingRecord()))
tok, rec := newToken(t)
require.NoError(t, store.Add(rec))
publisher := &testPublisher{err: errors.New("publish failed")}
gwc := newGatewayMock(&collaboration.GetShareResponse{
@@ -398,27 +555,56 @@ func TestRenewRollsBackOnPublishFailure(t *testing.T) {
s := newRenewService(t, store, gwc, publisher)
err := s.Renew(context.Background(), testShareID)
err := s.Renew(context.Background(), testShareID, tok, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "publish failed")
got, err := store.Get(token.Hash(testShareID))
require.NoError(t, err)
assert.Empty(t, got.SecretHash)
assert.Equal(t, rec.SecretHash, got.SecretHash)
assert.Empty(t, got.PinHash)
assert.True(t, got.PinExpiry.IsZero())
assert.False(t, got.Redeemed)
}
func TestRenewRollbackConflictKeepsConcurrentChange(t *testing.T) {
store := storage.NewFileManager(t.TempDir())
tok, rec := newToken(t)
require.NoError(t, store.Add(rec))
publisher := &mutatingPublisher{testPublisher: testPublisher{err: errors.New("publish failed")}}
publisher.onPublish = func() {
require.NoError(t, store.Update(rec.ShareIDHash, func(r *storage.Record) error {
r.SecretHash = "concurrent"
return nil
}))
}
s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
Share: newGuestShare(time.Now().Add(time.Hour)),
}), publisher)
err := s.Renew(context.Background(), testShareID, tok, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "publish failed")
assert.Contains(t, err.Error(), "rollback failed")
got, err := store.Get(rec.ShareIDHash)
require.NoError(t, err)
assert.Equal(t, "concurrent", got.SecretHash)
}
func TestRenewShareNotFound(t *testing.T) {
store := storage.NewFileManager(t.TempDir())
require.NoError(t, store.Add(existingRecord()))
tok, rec := newToken(t)
require.NoError(t, store.Add(rec))
s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{
Status: &rpc.Status{Code: rpc.Code_CODE_NOT_FOUND},
}), &testPublisher{})
err := s.Renew(context.Background(), testShareID)
err := s.Renew(context.Background(), testShareID, tok, "")
require.Error(t, err)
var ge *GuestError
@@ -429,14 +615,15 @@ func TestRenewShareNotFound(t *testing.T) {
func TestRenewShareExpired(t *testing.T) {
store := storage.NewFileManager(t.TempDir())
require.NoError(t, store.Add(existingRecord()))
tok, rec := newToken(t)
require.NoError(t, store.Add(rec))
s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
Share: newGuestShare(time.Now().Add(-time.Hour)),
}), &testPublisher{})
err := s.Renew(context.Background(), testShareID)
err := s.Renew(context.Background(), testShareID, tok, "")
require.Error(t, err)
var ge *GuestError
@@ -4,11 +4,14 @@
package jwt
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
var ErrInvalidSession = errors.New("invalid session")
type jwtClaims struct {
ShareID string `json:"permissionId"`
jwt.RegisteredClaims
@@ -35,3 +38,17 @@ func (m *JwtService) Sign(shareID string) (string, error) {
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(m.secret)
}
// Verify returns the share id of a session token, ignoring its expiry so an
// expired session can still be used to renew the guest link.
func (m *JwtService) Verify(tokenString string) (string, error) {
claims := &jwtClaims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(*jwt.Token) (any, error) {
return m.secret, nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithoutClaimsValidation())
if err != nil || !token.Valid || claims.ShareID == "" {
return "", ErrInvalidSession
}
return claims.ShareID, nil
}
@@ -42,3 +42,52 @@ func TestParseExpired(t *testing.T) {
_, err = parseClaims(t, m, tok)
assert.Error(t, err)
}
func TestVerify(t *testing.T) {
m := NewJwtService("test-secret", time.Hour)
tok, err := m.Sign("share-id")
require.NoError(t, err)
shareID, err := m.Verify(tok)
require.NoError(t, err)
assert.Equal(t, "share-id", shareID)
}
func TestVerifyExpired(t *testing.T) {
m := NewJwtService("test-secret", -time.Minute)
tok, err := m.Sign("share-id")
require.NoError(t, err)
shareID, err := m.Verify(tok)
require.NoError(t, err)
assert.Equal(t, "share-id", shareID)
}
func TestVerifyInvalid(t *testing.T) {
m := NewJwtService("test-secret", time.Hour)
tok, err := m.Sign("share-id")
require.NoError(t, err)
other, err := NewJwtService("other-secret", time.Hour).Sign("share-id")
require.NoError(t, err)
tests := []struct {
name string
token string
}{
{name: "garbage", token: "not-a-token"},
{name: "empty", token: ""},
{name: "wrong secret", token: other},
{name: "tampered", token: tok + "x"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := m.Verify(tt.token)
assert.ErrorIs(t, err, ErrInvalidSession)
})
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ func Generate() (string, error) {
}
func Hash(p string) (string, error) {
if p == "" {
if len(p) != Length {
return "", ErrInvalidPin
}
@@ -41,9 +41,11 @@ func TestHashProducesArgon2id(t *testing.T) {
assert.NotEqual(t, "123456", h)
}
func TestHashEmptyPin(t *testing.T) {
_, err := Hash("")
assert.ErrorIs(t, err, ErrInvalidPin)
func TestHashInvalidLength(t *testing.T) {
for _, p := range []string{"", "12345", "1234567"} {
_, err := Hash(p)
assert.ErrorIs(t, err, ErrInvalidPin)
}
}
func TestVerify(t *testing.T) {
@@ -54,7 +54,7 @@ func (s *FileManager) Add(rec Record) error {
}
// Get returns the record for the given share id hash.
func (s *FileManager) Get(shareIDHash string) (Record, error) {
func (s *FileManager) Get(shareIDHash string) (*Record, error) {
return s.get(shareIDHash)
}
@@ -83,8 +83,7 @@ func (s *FileManager) Remove(shareIDHash string) error {
return nil
}
// Update applies fn to the record under the record lock and writes it back.
// If fn returns an error the record is left unchanged.
// Update applies fn to the record under lock and writes it back.
func (s *FileManager) Update(shareIDHash string, fn func(*Record) error) error {
lock, err := s.lockRecord(shareIDHash)
if err != nil {
@@ -97,11 +96,45 @@ func (s *FileManager) Update(shareIDHash string, fn func(*Record) error) error {
return err
}
if err := fn(&rec); err != nil {
if err := fn(rec); err != nil {
return err
}
return s.upsert(rec)
rec.Revision++
return s.upsert(*rec)
}
// UpdateFrom applies fn to the record under lock and writes it back, unless it
// changed since seen (its revision advanced), in which case it returns
// ErrConflict. It returns the updated record.
func (s *FileManager) UpdateFrom(seen Record, fn func(*Record) error) (*Record, error) {
lock, err := s.lockRecord(seen.ShareIDHash)
if err != nil {
return nil, err
}
defer func() { _ = lock.Unlock() }()
rec, err := s.get(seen.ShareIDHash)
if err != nil {
return nil, err
}
if seen.Revision < rec.Revision {
return nil, ErrConflict
}
if err := fn(rec); err != nil {
return nil, err
}
rec.Revision = seen.Revision + 1
if err := s.upsert(*rec); err != nil {
return nil, err
}
return rec, nil
}
func (s *FileManager) lockRecord(shareIDHash string) (*flock.Flock, error) {
@@ -141,23 +174,23 @@ func (s *FileManager) upsert(rec Record) error {
return renameio.WriteFile(p, data, filePerm)
}
func (s *FileManager) get(shareIDHash string) (Record, error) {
func (s *FileManager) get(shareIDHash string) (*Record, error) {
p, err := s.path(shareIDHash)
if err != nil {
return Record{}, err
return nil, err
}
data, err := os.ReadFile(p)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return Record{}, ErrNotFound
return nil, ErrNotFound
}
return Record{}, err
return nil, err
}
rec := Record{}
if err := json.Unmarshal(data, &rec); err != nil {
return Record{}, err
rec := &Record{}
if err := json.Unmarshal(data, rec); err != nil {
return nil, err
}
return rec, nil
@@ -37,7 +37,7 @@ func TestFileManagerAddGet(t *testing.T) {
got, err := s.Get(rec.ShareIDHash)
require.NoError(t, err)
assert.Equal(t, rec, got)
assert.Equal(t, rec, *got)
}
func TestFileManagerGetMissing(t *testing.T) {
@@ -72,6 +72,8 @@ func TestFileManagerInvalidHash(t *testing.T) {
require.ErrorIs(t, s.Remove("ab"), ErrInvalidHash)
require.ErrorIs(t, s.Add(Record{ShareIDHash: "ab"}), ErrInvalidHash)
require.ErrorIs(t, s.Update("ab", func(*Record) error { return nil }), ErrInvalidHash)
_, err = s.UpdateFrom(Record{ShareIDHash: "ab"}, func(*Record) error { return nil })
require.ErrorIs(t, err, ErrInvalidHash)
}
func TestFileManagerRemove(t *testing.T) {
@@ -112,6 +114,7 @@ func TestFileManagerUpdate(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "pinhash", got.PinHash)
assert.True(t, got.Redeemed)
assert.Equal(t, uint64(1), got.Revision)
}
func TestFileManagerUpdateErrorLeavesRecordUnchanged(t *testing.T) {
@@ -130,7 +133,7 @@ func TestFileManagerUpdateErrorLeavesRecordUnchanged(t *testing.T) {
got, err := s.Get(rec.ShareIDHash)
require.NoError(t, err)
assert.Equal(t, rec, got)
assert.Equal(t, rec, *got)
}
func TestFileManagerUpdateMissing(t *testing.T) {
@@ -141,6 +144,65 @@ func TestFileManagerUpdateMissing(t *testing.T) {
assert.ErrorIs(t, err, ErrNotFound)
}
func TestFileManagerUpdateFrom(t *testing.T) {
dir := t.TempDir()
s := NewFileManager(dir)
rec := newRecord("e0123456-7890-abcd-ef01-234567890abc")
require.NoError(t, s.Add(rec))
seen, err := s.Get(rec.ShareIDHash)
require.NoError(t, err)
updated, err := s.UpdateFrom(*seen, func(r *Record) error {
r.PinHash = "pinhash"
return nil
})
require.NoError(t, err)
require.NotNil(t, updated)
assert.Equal(t, seen.Revision+1, updated.Revision)
assert.Equal(t, "pinhash", updated.PinHash)
got, err := s.Get(rec.ShareIDHash)
require.NoError(t, err)
assert.Equal(t, *updated, *got)
}
func TestFileManagerUpdateFromConflict(t *testing.T) {
dir := t.TempDir()
s := NewFileManager(dir)
rec := newRecord("e0123456-7890-abcd-ef01-234567890abc")
require.NoError(t, s.Add(rec))
seen, err := s.Get(rec.ShareIDHash)
require.NoError(t, err)
require.NoError(t, s.Update(rec.ShareIDHash, func(r *Record) error {
r.SecretHash = "other"
return nil
}))
_, err = s.UpdateFrom(*seen, func(r *Record) error {
r.PinHash = "pinhash"
return nil
})
assert.ErrorIs(t, err, ErrConflict)
got, err := s.Get(rec.ShareIDHash)
require.NoError(t, err)
assert.Equal(t, "other", got.SecretHash)
assert.Empty(t, got.PinHash)
}
func TestFileManagerUpdateFromMissing(t *testing.T) {
dir := t.TempDir()
s := NewFileManager(dir)
_, err := s.UpdateFrom(Record{ShareIDHash: "doesnotexist"}, func(*Record) error { return nil })
assert.ErrorIs(t, err, ErrNotFound)
}
func TestFileManagerAddConcurrent(t *testing.T) {
dir := t.TempDir()
s := NewFileManager(dir)
@@ -88,22 +88,24 @@ func (_c *Manager_Add_Call) RunAndReturn(run func(rec storage.Record) error) *Ma
}
// Get provides a mock function for the type Manager
func (_mock *Manager) Get(shareIDHash string) (storage.Record, error) {
func (_mock *Manager) Get(shareIDHash string) (*storage.Record, error) {
ret := _mock.Called(shareIDHash)
if len(ret) == 0 {
panic("no return value specified for Get")
}
var r0 storage.Record
var r0 *storage.Record
var r1 error
if returnFunc, ok := ret.Get(0).(func(string) (storage.Record, error)); ok {
if returnFunc, ok := ret.Get(0).(func(string) (*storage.Record, error)); ok {
return returnFunc(shareIDHash)
}
if returnFunc, ok := ret.Get(0).(func(string) storage.Record); ok {
if returnFunc, ok := ret.Get(0).(func(string) *storage.Record); ok {
r0 = returnFunc(shareIDHash)
} else {
r0 = ret.Get(0).(storage.Record)
if ret.Get(0) != nil {
r0 = ret.Get(0).(*storage.Record)
}
}
if returnFunc, ok := ret.Get(1).(func(string) error); ok {
r1 = returnFunc(shareIDHash)
@@ -137,12 +139,12 @@ func (_c *Manager_Get_Call) Run(run func(shareIDHash string)) *Manager_Get_Call
return _c
}
func (_c *Manager_Get_Call) Return(record storage.Record, err error) *Manager_Get_Call {
func (_c *Manager_Get_Call) Return(record *storage.Record, err error) *Manager_Get_Call {
_c.Call.Return(record, err)
return _c
}
func (_c *Manager_Get_Call) RunAndReturn(run func(shareIDHash string) (storage.Record, error)) *Manager_Get_Call {
func (_c *Manager_Get_Call) RunAndReturn(run func(shareIDHash string) (*storage.Record, error)) *Manager_Get_Call {
_c.Call.Return(run)
return _c
}
@@ -254,3 +256,71 @@ func (_c *Manager_Update_Call) RunAndReturn(run func(shareIDHash string, fn func
_c.Call.Return(run)
return _c
}
// UpdateFrom provides a mock function for the type Manager
func (_mock *Manager) UpdateFrom(seen storage.Record, fn func(*storage.Record) error) (*storage.Record, error) {
ret := _mock.Called(seen, fn)
if len(ret) == 0 {
panic("no return value specified for UpdateFrom")
}
var r0 *storage.Record
var r1 error
if returnFunc, ok := ret.Get(0).(func(storage.Record, func(*storage.Record) error) (*storage.Record, error)); ok {
return returnFunc(seen, fn)
}
if returnFunc, ok := ret.Get(0).(func(storage.Record, func(*storage.Record) error) *storage.Record); ok {
r0 = returnFunc(seen, fn)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*storage.Record)
}
}
if returnFunc, ok := ret.Get(1).(func(storage.Record, func(*storage.Record) error) error); ok {
r1 = returnFunc(seen, fn)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Manager_UpdateFrom_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateFrom'
type Manager_UpdateFrom_Call struct {
*mock.Call
}
// UpdateFrom is a helper method to define mock.On call
// - seen storage.Record
// - fn func(*storage.Record) error
func (_e *Manager_Expecter) UpdateFrom(seen any, fn any) *Manager_UpdateFrom_Call {
return &Manager_UpdateFrom_Call{Call: _e.mock.On("UpdateFrom", seen, fn)}
}
func (_c *Manager_UpdateFrom_Call) Run(run func(seen storage.Record, fn func(*storage.Record) error)) *Manager_UpdateFrom_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 storage.Record
if args[0] != nil {
arg0 = args[0].(storage.Record)
}
var arg1 func(*storage.Record) error
if args[1] != nil {
arg1 = args[1].(func(*storage.Record) error)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *Manager_UpdateFrom_Call) Return(record *storage.Record, err error) *Manager_UpdateFrom_Call {
_c.Call.Return(record, err)
return _c
}
func (_c *Manager_UpdateFrom_Call) RunAndReturn(run func(seen storage.Record, fn func(*storage.Record) error) (*storage.Record, error)) *Manager_UpdateFrom_Call {
_c.Call.Return(run)
return _c
}
@@ -10,6 +10,7 @@ import (
var ErrNotFound = errors.New("record not found")
var ErrInvalidHash = errors.New("invalid share id hash")
var ErrConflict = errors.New("record was modified concurrently")
// Record holds the data persisted for a guest share token.
type Record struct {
@@ -20,11 +21,13 @@ type Record struct {
Expiry time.Time `json:"expiry,omitzero"`
PinExpiry time.Time `json:"pinexpiry,omitzero"`
Redeemed bool `json:"redeemed"`
Revision uint64 `json:"revision"`
}
type Manager interface {
Add(rec Record) error
Get(shareIDHash string) (Record, error)
Get(shareIDHash string) (*Record, error)
Remove(shareIDHash string) error
Update(shareIDHash string, fn func(*Record) error) error
UpdateFrom(seen Record, fn func(*Record) error) (*Record, error)
}