This commit is contained in:
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.1.0-preview.20 — 2026-09-04
|
||||||
|
|
||||||
|
- Extend the direct-owner transaction boundary to invitations. Creating or
|
||||||
|
revoking an invitation that grants the configured owner role now requires
|
||||||
|
the actor to remain an active direct owner after the SQLite write lock is
|
||||||
|
acquired.
|
||||||
|
- Preserve application-owned permission policy for ordinary invitations while
|
||||||
|
preventing a broad access-management role, stale ceremony, or alternate
|
||||||
|
repository call from creating or cancelling owner access.
|
||||||
|
- Pass the configured owner role explicitly through invitation repository
|
||||||
|
mutations so non-SQLite adapters cannot silently omit the invariant.
|
||||||
|
|
||||||
## v0.1.0-preview.19 — 2026-09-04
|
## v0.1.0-preview.19 — 2026-09-04
|
||||||
|
|
||||||
- Require a current active direct owner for every direct-role transition to or
|
- Require a current active direct owner for every direct-role transition to or
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and
|
|||||||
deployment. Adopt one boundary at a time; Go compiles and links only the
|
deployment. Adopt one boundary at a time; Go compiles and links only the
|
||||||
packages you import.
|
packages you import.
|
||||||
|
|
||||||
> **Public preview:** `v0.1.0-preview.19`. APIs may change before a stable
|
> **Public preview:** `v0.1.0-preview.20`. APIs may change before a stable
|
||||||
> release. Linux is the maintained release platform.
|
> release. Linux is the maintained release platform.
|
||||||
|
|
||||||
## Why Web Foundations?
|
## Why Web Foundations?
|
||||||
@@ -57,14 +57,14 @@ owns—and, just as importantly, what remains application policy.
|
|||||||
Pin the preview in an application module:
|
Pin the preview in an application module:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web@v0.1.0-preview.19
|
go get gamertan.com/web@v0.1.0-preview.20
|
||||||
go mod verify
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
An application may name the first package it intends to adopt:
|
An application may name the first package it intends to adopt:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.19
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.20
|
||||||
```
|
```
|
||||||
|
|
||||||
The version belongs to the `gamertan.com/web` module. See the
|
The version belongs to the `gamertan.com/web` module. See the
|
||||||
|
|||||||
@@ -123,8 +123,8 @@ func (store *Store) CreateApplicationService(ctx context.Context, application or
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation, audit organizations.AuditEvent) error {
|
func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation, ownerRole string, audit organizations.AuditEvent) error {
|
||||||
if !opaqueID(invitation.ID) || zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.DirectRole != "" && !safeName(invitation.DirectRole) || !validInvitationTeamIDs(invitation.TeamIDs) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() || !invitation.RevokedAt.IsZero() || !validOrganizationAudit(audit, invitation.OrganizationID) {
|
if !opaqueID(invitation.ID) || zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.DirectRole != "" && !safeName(invitation.DirectRole) || ownerRole != "" && !safeName(ownerRole) || !validInvitationTeamIDs(invitation.TeamIDs) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() || !invitation.RevokedAt.IsZero() || !validOrganizationAudit(audit, invitation.OrganizationID) {
|
||||||
return errors.New("authsqlite: invalid invitation")
|
return errors.New("authsqlite: invalid invitation")
|
||||||
}
|
}
|
||||||
teamIDs, err := json.Marshal(invitation.TeamIDs)
|
teamIDs, err := json.Marshal(invitation.TeamIDs)
|
||||||
@@ -136,6 +136,18 @@ func (store *Store) CreateInvitation(ctx context.Context, invitation organizatio
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
if err = lockActiveMembershipActor(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ownerRole != "" && invitation.DirectRole == ownerRole {
|
||||||
|
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID, ownerRole)
|
||||||
|
if ownerErr != nil {
|
||||||
|
return ownerErr
|
||||||
|
}
|
||||||
|
if !actorIsOwner {
|
||||||
|
return organizations.ErrOwnerAuthority
|
||||||
|
}
|
||||||
|
}
|
||||||
if err = validateInvitationTeams(ctx, tx, invitation.OrganizationID, invitation.TeamIDs); err != nil {
|
if err = validateInvitationTeams(ctx, tx, invitation.OrganizationID, invitation.TeamIDs); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -743,8 +755,8 @@ func validateInvitationTeams(ctx context.Context, tx *sql.Tx, organizationID str
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invitationID string, revokedAt time.Time, audit organizations.AuditEvent) error {
|
func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invitationID, ownerRole string, revokedAt time.Time, audit organizations.AuditEvent) error {
|
||||||
if !opaqueID(organizationID) || !opaqueID(invitationID) || revokedAt.IsZero() || !validOrganizationAudit(audit, organizationID) {
|
if !opaqueID(organizationID) || !opaqueID(invitationID) || ownerRole != "" && !safeName(ownerRole) || revokedAt.IsZero() || !validOrganizationAudit(audit, organizationID) {
|
||||||
return organizations.ErrInvitationNotFound
|
return organizations.ErrInvitationNotFound
|
||||||
}
|
}
|
||||||
tx, err := store.db.BeginTx(ctx, nil)
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
@@ -752,6 +764,25 @@ func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invita
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var directRole string
|
||||||
|
if err = tx.QueryRowContext(ctx, `SELECT direct_role FROM gwf_organization_invitations WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, organizationID, invitationID).Scan(&directRole); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return organizations.ErrInvitationNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ownerRole != "" && directRole == ownerRole {
|
||||||
|
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, organizationID, audit.ActorUserID, ownerRole)
|
||||||
|
if ownerErr != nil {
|
||||||
|
return ownerErr
|
||||||
|
}
|
||||||
|
if !actorIsOwner {
|
||||||
|
return organizations.ErrOwnerAuthority
|
||||||
|
}
|
||||||
|
}
|
||||||
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET revoked_at=? WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, revokedAt.Unix(), organizationID, invitationID)
|
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET revoked_at=? WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, revokedAt.Unix(), organizationID, invitationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -653,6 +653,80 @@ func TestOrganizationRoleAdministrationIsAtomicAndProtectsOwners(t *testing.T) {
|
|||||||
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE id=?`, replacement.ID, 0)
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE id=?`, replacement.ID, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOwnerInvitationsRequireDirectOwnerAuthority(t *testing.T) {
|
||||||
|
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "invitation.owner", Email: "invitation-owner@example.test", DisplayName: "Invitation Owner", Password: "correct horse battery staple"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manager, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "invitation.manager", Email: "invitation-manager@example.test", DisplayName: "Invitation Manager", Password: "correct horse battery staple"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
organizationService, err := organizations.New(store, organizations.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
organization, err := organizationService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "invitation-authority", Name: "Invitation Authority", OwnerUserID: owner.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, _, err := organizationService.Invite(t.Context(), organization.ID, manager.Email, owner.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = organizationService.AcceptInvitation(t.Context(), raw, manager.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
policy := access.Policy{
|
||||||
|
Roles: map[string]string{"owner": "Owner", "site-admin": "Site administrator", "viewer": "Viewer"},
|
||||||
|
Permissions: map[string]string{"site.access.manage": "Manage site access"},
|
||||||
|
Grants: map[string][]string{"owner": {"site.access.manage"}, "site-admin": {"site.access.manage"}, "viewer": {}},
|
||||||
|
}
|
||||||
|
accessService, err := access.New(store, policy, access.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = accessService.Seed(t.Context()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: manager.ID, Role: "site-admin", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err = organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "blocked-owner@example.test", InvitedByUserID: manager.ID, DirectRole: "owner", Lifetime: time.Hour}); !errors.Is(err, organizations.ErrOwnerAuthority) {
|
||||||
|
t.Fatalf("non-owner owner invitation err=%v", err)
|
||||||
|
}
|
||||||
|
_, viewerInvitation, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "viewer@example.test", InvitedByUserID: manager.ID, DirectRole: "viewer", Lifetime: time.Hour})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("non-owner ordinary invitation err=%v", err)
|
||||||
|
}
|
||||||
|
_, ownerInvitation, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "new-owner@example.test", InvitedByUserID: owner.ID, DirectRole: "owner", Lifetime: time.Hour})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("owner invitation err=%v", err)
|
||||||
|
}
|
||||||
|
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, ownerInvitation.ID, manager.ID, "request-manager-owner-revoke"); !errors.Is(err, organizations.ErrOwnerAuthority) {
|
||||||
|
t.Fatalf("non-owner owner invitation revocation err=%v", err)
|
||||||
|
}
|
||||||
|
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, viewerInvitation.ID, manager.ID, "request-manager-viewer-revoke"); err != nil {
|
||||||
|
t.Fatalf("ordinary invitation revocation err=%v", err)
|
||||||
|
}
|
||||||
|
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, ownerInvitation.ID, owner.ID, "request-owner-owner-revoke"); err != nil {
|
||||||
|
t.Fatalf("owner invitation revocation err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOptimisticMembershipLifecycleIsSerializedAndAtomic(t *testing.T) {
|
func TestOptimisticMembershipLifecycleIsSerializedAndAtomic(t *testing.T) {
|
||||||
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
|
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -79,3 +79,9 @@ application concern belongs in the shared module.
|
|||||||
that invariant into the same SQLite transactions as direct-role and
|
that invariant into the same SQLite transactions as direct-role and
|
||||||
membership changes, while leaving the application's role vocabulary and UI
|
membership changes, while leaving the application's role vocabulary and UI
|
||||||
policy application-owned.
|
policy application-owned.
|
||||||
|
- Gamertan's invitation work found the same authority boundary before a route
|
||||||
|
was exposed: Site Admin must be able to invite ordinary staff without being
|
||||||
|
able to grant or cancel Owner access. Preview 20 passes the configured owner
|
||||||
|
role into invitation mutations and rechecks a current active direct Owner
|
||||||
|
after acquiring the SQLite write lock. The application still owns fresh
|
||||||
|
authentication, recipient delivery, and the one-time secret presentation.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its
|
|||||||
module checksum:
|
module checksum:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.19
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.20
|
||||||
go mod verify
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
|
|||||||
and request the containing module at an exact version:
|
and request the containing module at an exact version:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.19
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.20
|
||||||
```
|
```
|
||||||
|
|
||||||
Only imported packages are compiled and linked. The packages nevertheless
|
Only imported packages are compiled and linked. The packages nevertheless
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ expiring, single-use invitations. An invitation may carry one direct role and
|
|||||||
up to sixteen reviewed team memberships. Acceptance verifies that the
|
up to sixteen reviewed team memberships. Acceptance verifies that the
|
||||||
authenticated user's normalized email matches and applies the membership,
|
authenticated user's normalized email matches and applies the membership,
|
||||||
role, teams, consumption marker, and audit event in one transaction.
|
role, teams, consumption marker, and audit event in one transaction.
|
||||||
|
When `OwnerRole` is configured, creating or revoking an invitation carrying
|
||||||
|
that role additionally requires a current active direct owner inside the same
|
||||||
|
SQLite transaction. A broad access-management permission may administer
|
||||||
|
ordinary invitations but cannot create or cancel owner access.
|
||||||
Applications own invitation pages, email or out-of-band delivery, active-source
|
Applications own invitation pages, email or out-of-band delivery, active-source
|
||||||
checks before archival, and account recovery.
|
checks before archival, and account recovery.
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ var (
|
|||||||
ErrRevisionConflict = errors.New("organizations: revision conflict")
|
ErrRevisionConflict = errors.New("organizations: revision conflict")
|
||||||
ErrPersonalOrganization = errors.New("organizations: personal organization lifecycle is fixed")
|
ErrPersonalOrganization = errors.New("organizations: personal organization lifecycle is fixed")
|
||||||
ErrLastOwner = errors.New("organizations: the last active direct owner must be preserved")
|
ErrLastOwner = errors.New("organizations: the last active direct owner must be preserved")
|
||||||
ErrOwnerAuthority = errors.New("organizations: a current direct owner must manage owner memberships")
|
ErrOwnerAuthority = errors.New("organizations: a current direct owner must manage owner access")
|
||||||
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
|
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
|
||||||
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
||||||
)
|
)
|
||||||
@@ -104,10 +104,10 @@ type Repository interface {
|
|||||||
CreateProject(context.Context, Project) error
|
CreateProject(context.Context, Project) error
|
||||||
CreateEnvironment(context.Context, Environment) error
|
CreateEnvironment(context.Context, Environment) error
|
||||||
CreateApplicationService(context.Context, ApplicationService) error
|
CreateApplicationService(context.Context, ApplicationService) error
|
||||||
CreateInvitation(context.Context, Invitation, AuditEvent) error
|
CreateInvitation(context.Context, Invitation, string, AuditEvent) error
|
||||||
InvitationByDigest(context.Context, [32]byte, time.Time) (Invitation, error)
|
InvitationByDigest(context.Context, [32]byte, time.Time) (Invitation, error)
|
||||||
Invitations(context.Context, string, int) ([]Invitation, error)
|
Invitations(context.Context, string, int) ([]Invitation, error)
|
||||||
RevokeInvitation(context.Context, string, string, time.Time, AuditEvent) error
|
RevokeInvitation(context.Context, string, string, string, time.Time, AuditEvent) error
|
||||||
AcceptInvitation(context.Context, [32]byte, string, time.Time, AuditEvent) error
|
AcceptInvitation(context.Context, [32]byte, string, time.Time, AuditEvent) error
|
||||||
OrganizationMemberships(context.Context, string, int) ([]Membership, error)
|
OrganizationMemberships(context.Context, string, int) ([]Membership, error)
|
||||||
MembershipsForUser(context.Context, string) ([]Membership, error)
|
MembershipsForUser(context.Context, string) ([]Membership, error)
|
||||||
@@ -314,7 +314,7 @@ func (service *Service) InviteWithAccess(ctx context.Context, input InviteWithAc
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", Invitation{}, err
|
return "", Invitation{}, err
|
||||||
}
|
}
|
||||||
if err = service.repository.CreateInvitation(ctx, invitation, audit); err != nil {
|
if err = service.repository.CreateInvitation(ctx, invitation, service.ownerRole, audit); err != nil {
|
||||||
return "", Invitation{}, err
|
return "", Invitation{}, err
|
||||||
}
|
}
|
||||||
return raw, invitation, nil
|
return raw, invitation, nil
|
||||||
@@ -559,7 +559,7 @@ func (service *Service) RevokeInvitation(ctx context.Context, organizationID, in
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return service.repository.RevokeInvitation(ctx, organizationID, invitationID, now, audit)
|
return service.repository.RevokeInvitation(ctx, organizationID, invitationID, service.ownerRole, now, audit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) Repository() Repository { return service.repository }
|
func (service *Service) Repository() Repository { return service.repository }
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ func (*repositoryStub) CreateEnvironment(context.Context, Environment) error { r
|
|||||||
func (*repositoryStub) CreateApplicationService(context.Context, ApplicationService) error {
|
func (*repositoryStub) CreateApplicationService(context.Context, ApplicationService) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (repository *repositoryStub) CreateInvitation(_ context.Context, invitation Invitation, _ AuditEvent) error {
|
func (repository *repositoryStub) CreateInvitation(_ context.Context, invitation Invitation, _ string, _ AuditEvent) error {
|
||||||
repository.invitation = invitation
|
repository.invitation = invitation
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -113,7 +113,7 @@ func (repository *repositoryStub) InvitationByDigest(context.Context, [32]byte,
|
|||||||
func (*repositoryStub) Invitations(context.Context, string, int) ([]Invitation, error) {
|
func (*repositoryStub) Invitations(context.Context, string, int) ([]Invitation, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (*repositoryStub) RevokeInvitation(context.Context, string, string, time.Time, AuditEvent) error {
|
func (*repositoryStub) RevokeInvitation(context.Context, string, string, string, time.Time, AuditEvent) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte, userID string, _ time.Time, _ AuditEvent) error {
|
func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte, userID string, _ time.Time, _ AuditEvent) error {
|
||||||
|
|||||||
Reference in New Issue
Block a user