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
128 changes: 128 additions & 0 deletions app/controlplane/internal/biz/apitoken.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//
// Copyright 2023 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package biz

import (
"context"
"fmt"
"time"

"github.com/chainloop-dev/chainloop/app/controlplane/internal/conf"
"github.com/chainloop-dev/chainloop/app/controlplane/internal/jwt"
"github.com/chainloop-dev/chainloop/app/controlplane/internal/jwt/apitoken"
"github.com/go-kratos/kratos/v2/log"
"github.com/google/uuid"
)

// API Token is used for unattended access to the control plane API.
type APIToken struct {
ID uuid.UUID
Description string
// This is the JWT value returned only during creation
JWT string
// Tokens are scoped to organizations
OrganizationID uuid.UUID
CreatedAt *time.Time
// When the token expires
ExpiresAt *time.Time
// When the token was manually revoked
RevokedAt *time.Time
}

type APITokenRepo interface {
Create(ctx context.Context, description *string, expiresAt *time.Time, organizationID uuid.UUID) (*APIToken, error)
List(ctx context.Context, orgID uuid.UUID, includeRevoked bool) ([]*APIToken, error)
Revoke(ctx context.Context, orgID, ID uuid.UUID) error
}

type APITokenUseCase struct {
apiTokenRepo APITokenRepo
logger *log.Helper
jwtBuilder *apitoken.Builder
}

func NewAPITokenUseCase(apiTokenRepo APITokenRepo, conf *conf.Auth, logger log.Logger) (*APITokenUseCase, error) {
uc := &APITokenUseCase{
apiTokenRepo: apiTokenRepo,
logger: log.NewHelper(logger),
}

// Create the JWT builder for the API token
b, err := apitoken.NewBuilder(
apitoken.WithIssuer(jwt.DefaultIssuer),
apitoken.WithKeySecret(conf.GeneratedJwsHmacSecret),
)
if err != nil {
return nil, fmt.Errorf("creating jwt builder: %w", err)
}

uc.jwtBuilder = b
return uc, nil
}

// expires in is a string that can be parsed by time.ParseDuration
func (uc *APITokenUseCase) Create(ctx context.Context, description *string, expiresIn *time.Duration, orgID string) (*APIToken, error) {
orgUUID, err := uuid.Parse(orgID)
if err != nil {
return nil, NewErrInvalidUUID(err)
}

// If expiration is provided we store it
// we also validate that it's at least 24 hours and valid string format
var expiresAt *time.Time
if expiresIn != nil {
expiresAt = new(time.Time)
*expiresAt = time.Now().Add(*expiresIn)
}

// NOTE: the expiration time is stored just for reference, it's also encoded in the JWT
// We store it since Chainloop will not have access to the JWT to check the expiration once created
token, err := uc.apiTokenRepo.Create(ctx, description, expiresAt, orgUUID)
if err != nil {
return nil, fmt.Errorf("storing token: %w", err)
}

// generate the JWT
token.JWT, err = uc.jwtBuilder.GenerateJWT(orgID, token.ID.String(), expiresAt)
if err != nil {
return nil, fmt.Errorf("generating jwt: %w", err)
}

return token, nil
}

func (uc *APITokenUseCase) List(ctx context.Context, orgID string, includeRevoked bool) ([]*APIToken, error) {
orgUUID, err := uuid.Parse(orgID)
if err != nil {
return nil, NewErrInvalidUUID(err)
}

return uc.apiTokenRepo.List(ctx, orgUUID, includeRevoked)
}

func (uc *APITokenUseCase) Revoke(ctx context.Context, orgID, id string) error {
orgUUID, err := uuid.Parse(orgID)
if err != nil {
return NewErrInvalidUUID(err)
}

uuid, err := uuid.Parse(id)
if err != nil {
return NewErrInvalidUUID(err)
}

return uc.apiTokenRepo.Revoke(ctx, orgUUID, uuid)
}
202 changes: 202 additions & 0 deletions app/controlplane/internal/biz/apitoken_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
//
// Copyright 2023 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package biz_test

import (
"context"
"testing"
"time"

"github.com/chainloop-dev/chainloop/app/controlplane/internal/biz"
"github.com/chainloop-dev/chainloop/app/controlplane/internal/biz/testhelpers"
"github.com/chainloop-dev/chainloop/app/controlplane/internal/jwt/apitoken"
"github.com/golang-jwt/jwt"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)

func (s *apiTokenTestSuite) TestCreate() {
ctx := context.Background()
s.T().Run("invalid org ID", func(t *testing.T) {
token, err := s.APIToken.Create(ctx, nil, nil, "deadbeef")
s.Error(err)
s.True(biz.IsErrInvalidUUID(err))
s.Nil(token)
})

s.T().Run("happy path without expiration nor description", func(t *testing.T) {
token, err := s.APIToken.Create(ctx, nil, nil, s.org.ID)
s.NoError(err)
s.NotNil(token.ID)
s.Equal(s.org.ID, token.OrganizationID.String())
s.Empty(token.Description)
s.Nil(token.ExpiresAt)
s.Nil(token.RevokedAt)
s.NotNil(token.JWT)
})

s.T().Run("happy path with description and expiration", func(t *testing.T) {
token, err := s.APIToken.Create(ctx, toPtrS("tokenStr"), toPtrDuration(24*time.Hour), s.org.ID)
s.NoError(err)
s.Equal(s.org.ID, token.OrganizationID.String())
s.Equal("tokenStr", token.Description)
s.NotNil(token.ExpiresAt)
s.Nil(token.RevokedAt)
})
}

func (s *apiTokenTestSuite) TestRevoke() {
ctx := context.Background()

s.T().Run("invalid org ID", func(t *testing.T) {
err := s.APIToken.Revoke(ctx, "deadbeef", s.t1.ID.String())
s.Error(err)
s.True(biz.IsErrInvalidUUID(err))
})

s.T().Run("invalid token ID", func(t *testing.T) {
err := s.APIToken.Revoke(ctx, s.org.ID, "deadbeef")
s.Error(err)
s.True(biz.IsErrInvalidUUID(err))
})

s.T().Run("token not found in org", func(t *testing.T) {
err := s.APIToken.Revoke(ctx, s.org.ID, s.t3.ID.String())
s.Error(err)
s.True(biz.IsNotFound(err))
})

s.T().Run("token can be revoked once", func(t *testing.T) {
err := s.APIToken.Revoke(ctx, s.org.ID, s.t1.ID.String())
s.NoError(err)
tokens, err := s.APIToken.List(ctx, s.org.ID, true)
s.NoError(err)
s.Equal(s.t1.ID, tokens[0].ID)
// It's revoked
s.NotNil(tokens[0].RevokedAt)

// Can't be revoked twice
err = s.APIToken.Revoke(ctx, s.org.ID, s.t1.ID.String())
s.Error(err)
s.True(biz.IsNotFound(err))
})
}

func (s *apiTokenTestSuite) TestList() {
ctx := context.Background()
s.T().Run("invalid org ID", func(t *testing.T) {
tokens, err := s.APIToken.List(ctx, "deadbeef", false)
s.Error(err)
s.True(biz.IsErrInvalidUUID(err))
s.Nil(tokens)
})

s.T().Run("returns empty list", func(t *testing.T) {
emptyOrg, err := s.Organization.Create(ctx, "org1")
require.NoError(s.T(), err)
tokens, err := s.APIToken.List(ctx, emptyOrg.ID, false)
s.NoError(err)
s.Len(tokens, 0)
})

s.T().Run("returns the tokens for that org", func(t *testing.T) {
var err error
tokens, err := s.APIToken.List(ctx, s.org.ID, false)
s.NoError(err)
require.Len(s.T(), tokens, 2)
s.Equal(s.t1.ID, tokens[0].ID)
s.Equal(s.t2.ID, tokens[1].ID)

tokens, err = s.APIToken.List(ctx, s.org2.ID, false)
s.NoError(err)
require.Len(s.T(), tokens, 1)
s.Equal(s.t3.ID, tokens[0].ID)
})

s.T().Run("doesn't return revoked by default", func(t *testing.T) {
// revoke one token
err := s.APIToken.Revoke(ctx, s.org.ID, s.t1.ID.String())
require.NoError(s.T(), err)
tokens, err := s.APIToken.List(ctx, s.org.ID, false)
s.NoError(err)
require.Len(s.T(), tokens, 1)
s.Equal(s.t2.ID, tokens[0].ID)
})

s.T().Run("doesn't return revoked unless requested", func(t *testing.T) {
// revoke one token
tokens, err := s.APIToken.List(ctx, s.org.ID, true)
s.NoError(err)
require.Len(s.T(), tokens, 2)
s.Equal(s.t1.ID, tokens[0].ID)
s.Equal(s.t2.ID, tokens[1].ID)
})
}

func (s *apiTokenTestSuite) TestGeneratedJWT() {
token, err := s.APIToken.Create(context.Background(), nil, toPtrDuration(24*time.Hour), s.org.ID)
s.NoError(err)
require.NotNil(s.T(), token)

claims := &apitoken.CustomClaims{}
tokenInfo, err := jwt.ParseWithClaims(token.JWT, claims, func(_ *jwt.Token) (interface{}, error) {
return []byte("test"), nil
})

require.NoError(s.T(), err)
s.True(tokenInfo.Valid)
// The resulting JWT should have the same org, token ID and expiration time than
// the reference in the DB
s.Equal(token.OrganizationID.String(), claims.OrgID)
s.Equal(token.ID.String(), claims.ID)
s.Equal(token.ExpiresAt.Truncate(time.Second), claims.ExpiresAt.Truncate(time.Second))
}

// Run the tests
func TestAPITokenUseCase(t *testing.T) {
suite.Run(t, new(apiTokenTestSuite))
}

// Utility struct to hold the test suite
type apiTokenTestSuite struct {
testhelpers.UseCasesEachTestSuite
org, org2 *biz.Organization
t1, t2, t3 *biz.APIToken
}

func (s *apiTokenTestSuite) SetupTest() {
t := s.T()
var err error
assert := assert.New(s.T())
ctx := context.Background()

s.TestingUseCases = testhelpers.NewTestingUseCases(t)
s.org, err = s.Organization.Create(ctx, "org1")
assert.NoError(err)
s.org2, err = s.Organization.Create(ctx, "org2")
assert.NoError(err)

// Create 2 tokens for org 1
s.t1, err = s.APIToken.Create(ctx, nil, nil, s.org.ID)
require.NoError(s.T(), err)
s.t2, err = s.APIToken.Create(ctx, nil, nil, s.org.ID)
require.NoError(s.T(), err)
// and 1 token for org 2
s.t3, err = s.APIToken.Create(ctx, nil, nil, s.org2.ID)
require.NoError(s.T(), err)
}
1 change: 1 addition & 0 deletions app/controlplane/internal/biz/biz.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ var ProviderSet = wire.NewSet(
NewWorkflowRunExpirerUseCase,
NewCASMappingUseCase,
NewReferrerUseCase,
NewAPITokenUseCase,
wire.Struct(new(NewIntegrationUseCaseOpts), "*"),
wire.Struct(new(NewUserUseCaseParams), "*"),
)
1 change: 1 addition & 0 deletions app/controlplane/internal/biz/membership.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type MembershipRepo interface {
FindByUser(ctx context.Context, userID uuid.UUID) ([]*Membership, error)
FindByOrg(ctx context.Context, orgID uuid.UUID) ([]*Membership, error)
FindByIDInUser(ctx context.Context, userID, ID uuid.UUID) (*Membership, error)
FindByOrgAndUser(ctx context.Context, orgID, userID uuid.UUID) (*Membership, error)
SetCurrent(ctx context.Context, ID uuid.UUID) (*Membership, error)
Create(ctx context.Context, orgID, userID uuid.UUID, current bool) (*Membership, error)
Delete(ctx context.Context, ID uuid.UUID) error
Expand Down
14 changes: 2 additions & 12 deletions app/controlplane/internal/biz/organization.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,20 +107,10 @@ func (uc *OrganizationUseCase) Update(ctx context.Context, userID, orgID string,
}

// Make sure that the organization exists and that the user is a member of it
memberships, err := uc.membershipRepo.FindByUser(ctx, userUUID)
membership, err := uc.membershipRepo.FindByOrgAndUser(ctx, orgUUID, userUUID)
if err != nil {
return nil, fmt.Errorf("failed to find memberships: %w", err)
}

var found bool
for _, m := range memberships {
if m.OrganizationID == orgUUID {
found = true
break
}
}

if !found {
} else if membership == nil {
return nil, NewErrNotFound("organization")
}

Expand Down
1 change: 1 addition & 0 deletions app/controlplane/internal/biz/testhelpers/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ type TestingUseCases struct {
CASMapping *biz.CASMappingUseCase
OrgInvitation *biz.OrgInvitationUseCase
Referrer *biz.ReferrerUseCase
APIToken *biz.APITokenUseCase
// Repositories that can be used for custom crafting of use-cases
Repos *TestingRepos
}
Expand Down
7 changes: 7 additions & 0 deletions app/controlplane/internal/biz/testhelpers/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading