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
11 changes: 10 additions & 1 deletion plugins/organizations/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ func (a *API) GetUserPermissionsInOrganization(ctx context.Context, userID strin
return nil, coreerrors.ErrUnauthorized
}

exists, err := a.organizationService.ExistsByID(ctx, organizationID)
if err != nil {
return nil, err
}
if !exists {
return nil, coreerrors.ErrForbidden
}

member, err := a.memberRepo.GetByOrganizationIDAndUserID(ctx, organizationID, userID)
if err != nil {
return nil, err
Expand All @@ -69,7 +77,8 @@ func (a *API) GetAllOrganizations(ctx context.Context, actor *models.Actor) ([]t
}

// GetAllOrganizationsUnscoped returns every organization in the system with no
// access filtering. Authorize the caller before using it.
// access filtering, excluding soft-deleted organizations. Authorize the caller
// before using it.
func (a *API) GetAllOrganizationsUnscoped(ctx context.Context) ([]types.Organization, error) {
return a.organizationService.GetAllOrganizationsUnscoped(ctx)
}
Expand Down
106 changes: 106 additions & 0 deletions plugins/organizations/api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package organizations

import (
"context"
"errors"
"testing"

"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"

coreerrors "github.com/Authula/authula/core/errors"
orgtests "github.com/Authula/authula/plugins/organizations/tests"
"github.com/Authula/authula/plugins/organizations/types"
)

func TestAPI_GetUserPermissionsInOrganization(t *testing.T) {
t.Parallel()

repoErr := errors.New("repository error")

tests := []struct {
name string
userID string
organizationID string
setup func(*orgtests.MockOrganizationService, *orgtests.MockOrganizationMemberRepository)
expectErr error
expectPerms []string
}{
{
name: "unauthorized without identifiers",
userID: "",
organizationID: "",
expectErr: coreerrors.ErrUnauthorized,
},
{
name: "missing organization is forbidden",
userID: "user-1",
organizationID: "org-1",
setup: func(orgService *orgtests.MockOrganizationService, _ *orgtests.MockOrganizationMemberRepository) {
orgService.On("ExistsByID", mock.Anything, "org-1").Return(false, nil).Once()
},
expectErr: coreerrors.ErrForbidden,
},
{
name: "existence check error is propagated",
userID: "user-1",
organizationID: "org-1",
setup: func(orgService *orgtests.MockOrganizationService, _ *orgtests.MockOrganizationMemberRepository) {
orgService.On("ExistsByID", mock.Anything, "org-1").Return(false, repoErr).Once()
},
expectErr: repoErr,
},
{
name: "non-member is forbidden",
userID: "user-1",
organizationID: "org-1",
setup: func(orgService *orgtests.MockOrganizationService, memberRepo *orgtests.MockOrganizationMemberRepository) {
orgService.On("ExistsByID", mock.Anything, "org-1").Return(true, nil).Once()
memberRepo.On("GetByOrganizationIDAndUserID", mock.Anything, "org-1", "user-1").Return(nil, nil).Once()
},
expectErr: coreerrors.ErrForbidden,
},
{
name: "member receives role permissions",
userID: "user-1",
organizationID: "org-1",
setup: func(orgService *orgtests.MockOrganizationService, memberRepo *orgtests.MockOrganizationMemberRepository) {
orgService.On("ExistsByID", mock.Anything, "org-1").Return(true, nil).Once()
memberRepo.On("GetByOrganizationIDAndUserID", mock.Anything, "org-1", "user-1").Return(&types.OrganizationMember{ID: "mem-1", OrganizationID: "org-1", UserID: "user-1", Role: "member"}, nil).Once()
},
expectPerms: []string{"organizations:read"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

orgService := &orgtests.MockOrganizationService{}
memberRepo := &orgtests.MockOrganizationMemberRepository{}
if tt.setup != nil {
tt.setup(orgService, memberRepo)
}

accessControl := orgtests.NewAccessControlServiceStub()
accessControl.RolePermissions["member"] = []string{"organizations:read"}

api := &API{
organizationService: orgService,
memberRepo: memberRepo,
accessControlService: accessControl,
}

perms, err := api.GetUserPermissionsInOrganization(context.Background(), tt.userID, tt.organizationID)
if tt.expectErr != nil {
require.Error(t, err)
require.ErrorIs(t, err, tt.expectErr)
return
}
require.NoError(t, err)
require.Equal(t, tt.expectPerms, perms)
orgService.AssertExpectations(t)
memberRepo.AssertExpectations(t)
})
}
}
32 changes: 19 additions & 13 deletions plugins/organizations/migrationset/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,13 @@ func organizationsSQLiteInitial() migrations.Migration {
slug VARCHAR(255) NOT NULL UNIQUE,
logo TEXT,
metadata TEXT,
deleted_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT
);`,
`CREATE INDEX IF NOT EXISTS idx_organizations_owner_id ON organizations(owner_id);`,
`CREATE INDEX IF NOT EXISTS idx_organizations_deleted_at ON organizations(deleted_at);`,
`CREATE INDEX IF NOT EXISTS idx_organizations_owner_created_id ON organizations(owner_id, created_at, id);`,
`DROP TRIGGER IF EXISTS update_organizations_updated_at_trigger;`,
`CREATE TRIGGER update_organizations_updated_at_trigger
Expand All @@ -56,7 +58,7 @@ func organizationsSQLiteInitial() migrations.Migration {
status VARCHAR(32) NOT NULL DEFAULT 'pending',
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT,
FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE CASCADE
);`,
`CREATE INDEX IF NOT EXISTS idx_organization_invitations_email ON organization_invitations(email);`,
Expand All @@ -73,7 +75,7 @@ func organizationsSQLiteInitial() migrations.Migration {
role VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE (organization_id, user_id)
);`,
Expand All @@ -97,7 +99,7 @@ func organizationsSQLiteInitial() migrations.Migration {
metadata TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT,
UNIQUE (organization_id, slug)
);`,
`CREATE INDEX IF NOT EXISTS idx_organization_teams_organization_id ON organization_teams(organization_id);`,
Expand Down Expand Up @@ -161,9 +163,10 @@ func organizationsPostgresInitial() migrations.Migration {
slug VARCHAR(255) NOT NULL UNIQUE,
logo TEXT,
metadata JSONB,
deleted_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT fk_organizations_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
CONSTRAINT fk_organizations_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT
);`,
`DROP TRIGGER IF EXISTS update_organizations_updated_at_trigger ON organizations;`,
`CREATE TRIGGER update_organizations_updated_at_trigger
Expand All @@ -172,6 +175,7 @@ func organizationsPostgresInitial() migrations.Migration {
EXECUTE FUNCTION organizations_set_updated_at_fn();`,
`CREATE INDEX IF NOT EXISTS idx_organizations_owner_id ON organizations(owner_id);`,
`CREATE INDEX IF NOT EXISTS idx_organizations_owner_created_id ON organizations(owner_id, created_at, id);`,
`CREATE INDEX IF NOT EXISTS idx_organizations_deleted_at ON organizations(deleted_at) WHERE deleted_at IS NULL;`,
// -----------------------------------
`CREATE TABLE IF NOT EXISTS organization_invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
Expand All @@ -183,7 +187,7 @@ func organizationsPostgresInitial() migrations.Migration {
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT fk_organization_invitations_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
CONSTRAINT fk_organization_invitations_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT,
CONSTRAINT fk_organization_invitations_inviter FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT chk_organization_invitations_status CHECK (status IN ('pending', 'accepted', 'rejected', 'revoked', 'expired'))
);`,
Expand All @@ -206,7 +210,7 @@ func organizationsPostgresInitial() migrations.Migration {
role VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT fk_organization_members_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
CONSTRAINT fk_organization_members_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT,
CONSTRAINT fk_organization_members_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT uq_organization_members_organization_user UNIQUE (organization_id, user_id)
);`,
Expand All @@ -229,7 +233,7 @@ func organizationsPostgresInitial() migrations.Migration {
metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT fk_organization_teams_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
CONSTRAINT fk_organization_teams_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT,
CONSTRAINT uq_organization_teams_organization_slug UNIQUE (organization_id, slug)
);`,
`DROP TRIGGER IF EXISTS update_organization_teams_updated_at_trigger ON organization_teams;`,
Expand Down Expand Up @@ -290,11 +294,13 @@ func organizationsMySQLInitial() migrations.Migration {
slug VARCHAR(255) NOT NULL UNIQUE,
logo TEXT NULL,
metadata JSON NULL,
deleted_at TIMESTAMP NULL DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_organizations_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_organizations_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT,
INDEX idx_organizations_owner_id (owner_id),
INDEX idx_organizations_owner_created_id (owner_id, created_at, id)
INDEX idx_organizations_deleted_at (deleted_at)
INDEX idx_organizations_owner_created_id (owner_id, created_at, id),
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;`,
// -----------------------------------
`CREATE TABLE IF NOT EXISTS organization_invitations (
Expand All @@ -307,7 +313,7 @@ func organizationsMySQLInitial() migrations.Migration {
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_organization_invitations_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
CONSTRAINT fk_organization_invitations_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT,
CONSTRAINT fk_organization_invitations_inviter FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT chk_organization_invitations_status CHECK (status IN ('pending', 'accepted', 'rejected', 'revoked', 'expired')),
INDEX idx_organization_invitations_organization_id (organization_id),
Expand All @@ -325,7 +331,7 @@ func organizationsMySQLInitial() migrations.Migration {
role VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_organization_members_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
CONSTRAINT fk_organization_members_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT,
CONSTRAINT fk_organization_members_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT uq_organization_members_organization_user UNIQUE (organization_id, user_id),
INDEX idx_organization_members_organization_id (organization_id),
Expand All @@ -343,7 +349,7 @@ func organizationsMySQLInitial() migrations.Migration {
metadata JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_organization_teams_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
CONSTRAINT fk_organization_teams_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT,
CONSTRAINT uq_organization_teams_organization_slug UNIQUE (organization_id, slug),
INDEX idx_organization_teams_organization_id (organization_id),
INDEX idx_organization_teams_slug (slug),
Expand Down
4 changes: 2 additions & 2 deletions plugins/organizations/openapi/openapi_docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func RegisterOpenAPIDocs(svc openapi.OpenAPIService) error {
"/organizations",
openapi.WithOperationID("listOrganizations"),
openapi.WithSummary("List organizations"),
openapi.WithDescription("Lists every organization the authenticated user can access, both the ones they own and the ones they are a member of, newest first. Results are paginated: `page` defaults to 1 and `limit` defaults to 10. `limit` is capped by the server's configured maximum page size, which defaults to 100; a larger value is silently reduced to the cap rather than rejected, and values below the minimum fall back to the defaults. The `pagination` object in the response always reports the values actually applied."),
openapi.WithDescription("Lists every organization the authenticated user can access."),
openapi.WithTags("Organizations"),
openapi.WithRequest(&types.ListOrganizationsRequest{}),
openapi.WithResponseStatus(http.StatusOK, &types.ListOrganizationsResponse{}),
Expand Down Expand Up @@ -57,7 +57,7 @@ func RegisterOpenAPIDocs(svc openapi.OpenAPIService) error {
"/organizations/{organization_id}",
openapi.WithOperationID("deleteOrganization"),
openapi.WithSummary("Delete organization"),
openapi.WithDescription("Deletes an organization."),
openapi.WithDescription("Soft-deletes an organization."),
openapi.WithTags("Organizations"),
openapi.WithRequest(&types.OrganizationID{}),
openapi.WithResponseStatus(http.StatusOK, &types.DeleteOrganizationResponse{}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,8 @@ func (r *BunOrganizationInvitationRepository) GetByOrganizationIDAndEmail(ctx co
return invitation, err
}

// pendingByEmailWhere matches invitations that are still awaiting a decision and
// have not expired. Both pending lookups order oldest first, because acceptance
// resolves role conflicts in favour of the earliest invitation.
const pendingByEmailWhere = "email = ? AND status = ? AND expires_at > ?"
const pendingByEmailWhere = "email = ? AND status = ? AND expires_at > ?" +
" AND organization_id IN (SELECT id FROM organizations WHERE deleted_at IS NULL)"

func (r *BunOrganizationInvitationRepository) ListAllPendingByEmail(ctx context.Context, email string, page int, limit int) ([]types.OrganizationInvitation, int, error) {
invites := make([]types.OrganizationInvitation, 0)
Expand Down Expand Up @@ -152,7 +150,7 @@ const invitationWithOrgColumns = `i.id, i.email, i.inviter_id, i.organization_id

const invitationWithOrgByOrganizationFrom = ` FROM organization_invitations i` +
` INNER JOIN organizations o ON o.id = i.organization_id` +
` WHERE i.organization_id = ?`
` WHERE i.organization_id = ? AND o.deleted_at IS NULL`

func mapToInvitationWithOrgResponse(row invitationOrgRow) types.GetOrganizationInvitationResponse {
return types.GetOrganizationInvitationResponse{
Expand Down Expand Up @@ -183,7 +181,7 @@ func (r *BunOrganizationInvitationRepository) GetByIDWithOrg(ctx context.Context
SELECT `+invitationWithOrgColumns+`
FROM organization_invitations i
INNER JOIN organizations o ON o.id = i.organization_id
WHERE i.id = ?
WHERE i.id = ? AND o.deleted_at IS NULL
`, invitationID).Scan(ctx, &row)
if err == sql.ErrNoRows {
return nil, nil
Expand Down
Loading
Loading