Skip to content

Commit 6ef1449

Browse files
authored
Api tokens reference persistence (#455)
Signed-off-by: Miguel Martinez Trivino <[email protected]>
1 parent e1f14ee commit 6ef1449

33 files changed

+3656
-25
lines changed
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
//
2+
// Copyright 2023 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package biz
17+
18+
import (
19+
"context"
20+
"fmt"
21+
"time"
22+
23+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/conf"
24+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/jwt"
25+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/jwt/apitoken"
26+
"github.com/go-kratos/kratos/v2/log"
27+
"github.com/google/uuid"
28+
)
29+
30+
// API Token is used for unattended access to the control plane API.
31+
type APIToken struct {
32+
ID uuid.UUID
33+
Description string
34+
// This is the JWT value returned only during creation
35+
JWT string
36+
// Tokens are scoped to organizations
37+
OrganizationID uuid.UUID
38+
CreatedAt *time.Time
39+
// When the token expires
40+
ExpiresAt *time.Time
41+
// When the token was manually revoked
42+
RevokedAt *time.Time
43+
}
44+
45+
type APITokenRepo interface {
46+
Create(ctx context.Context, description *string, expiresAt *time.Time, organizationID uuid.UUID) (*APIToken, error)
47+
List(ctx context.Context, orgID uuid.UUID, includeRevoked bool) ([]*APIToken, error)
48+
Revoke(ctx context.Context, orgID, ID uuid.UUID) error
49+
}
50+
51+
type APITokenUseCase struct {
52+
apiTokenRepo APITokenRepo
53+
logger *log.Helper
54+
jwtBuilder *apitoken.Builder
55+
}
56+
57+
func NewAPITokenUseCase(apiTokenRepo APITokenRepo, conf *conf.Auth, logger log.Logger) (*APITokenUseCase, error) {
58+
uc := &APITokenUseCase{
59+
apiTokenRepo: apiTokenRepo,
60+
logger: log.NewHelper(logger),
61+
}
62+
63+
// Create the JWT builder for the API token
64+
b, err := apitoken.NewBuilder(
65+
apitoken.WithIssuer(jwt.DefaultIssuer),
66+
apitoken.WithKeySecret(conf.GeneratedJwsHmacSecret),
67+
)
68+
if err != nil {
69+
return nil, fmt.Errorf("creating jwt builder: %w", err)
70+
}
71+
72+
uc.jwtBuilder = b
73+
return uc, nil
74+
}
75+
76+
// expires in is a string that can be parsed by time.ParseDuration
77+
func (uc *APITokenUseCase) Create(ctx context.Context, description *string, expiresIn *time.Duration, orgID string) (*APIToken, error) {
78+
orgUUID, err := uuid.Parse(orgID)
79+
if err != nil {
80+
return nil, NewErrInvalidUUID(err)
81+
}
82+
83+
// If expiration is provided we store it
84+
// we also validate that it's at least 24 hours and valid string format
85+
var expiresAt *time.Time
86+
if expiresIn != nil {
87+
expiresAt = new(time.Time)
88+
*expiresAt = time.Now().Add(*expiresIn)
89+
}
90+
91+
// NOTE: the expiration time is stored just for reference, it's also encoded in the JWT
92+
// We store it since Chainloop will not have access to the JWT to check the expiration once created
93+
token, err := uc.apiTokenRepo.Create(ctx, description, expiresAt, orgUUID)
94+
if err != nil {
95+
return nil, fmt.Errorf("storing token: %w", err)
96+
}
97+
98+
// generate the JWT
99+
token.JWT, err = uc.jwtBuilder.GenerateJWT(orgID, token.ID.String(), expiresAt)
100+
if err != nil {
101+
return nil, fmt.Errorf("generating jwt: %w", err)
102+
}
103+
104+
return token, nil
105+
}
106+
107+
func (uc *APITokenUseCase) List(ctx context.Context, orgID string, includeRevoked bool) ([]*APIToken, error) {
108+
orgUUID, err := uuid.Parse(orgID)
109+
if err != nil {
110+
return nil, NewErrInvalidUUID(err)
111+
}
112+
113+
return uc.apiTokenRepo.List(ctx, orgUUID, includeRevoked)
114+
}
115+
116+
func (uc *APITokenUseCase) Revoke(ctx context.Context, orgID, id string) error {
117+
orgUUID, err := uuid.Parse(orgID)
118+
if err != nil {
119+
return NewErrInvalidUUID(err)
120+
}
121+
122+
uuid, err := uuid.Parse(id)
123+
if err != nil {
124+
return NewErrInvalidUUID(err)
125+
}
126+
127+
return uc.apiTokenRepo.Revoke(ctx, orgUUID, uuid)
128+
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
//
2+
// Copyright 2023 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package biz_test
17+
18+
import (
19+
"context"
20+
"testing"
21+
"time"
22+
23+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/biz"
24+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/biz/testhelpers"
25+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/jwt/apitoken"
26+
"github.com/golang-jwt/jwt"
27+
28+
"github.com/stretchr/testify/assert"
29+
"github.com/stretchr/testify/require"
30+
"github.com/stretchr/testify/suite"
31+
)
32+
33+
func (s *apiTokenTestSuite) TestCreate() {
34+
ctx := context.Background()
35+
s.T().Run("invalid org ID", func(t *testing.T) {
36+
token, err := s.APIToken.Create(ctx, nil, nil, "deadbeef")
37+
s.Error(err)
38+
s.True(biz.IsErrInvalidUUID(err))
39+
s.Nil(token)
40+
})
41+
42+
s.T().Run("happy path without expiration nor description", func(t *testing.T) {
43+
token, err := s.APIToken.Create(ctx, nil, nil, s.org.ID)
44+
s.NoError(err)
45+
s.NotNil(token.ID)
46+
s.Equal(s.org.ID, token.OrganizationID.String())
47+
s.Empty(token.Description)
48+
s.Nil(token.ExpiresAt)
49+
s.Nil(token.RevokedAt)
50+
s.NotNil(token.JWT)
51+
})
52+
53+
s.T().Run("happy path with description and expiration", func(t *testing.T) {
54+
token, err := s.APIToken.Create(ctx, toPtrS("tokenStr"), toPtrDuration(24*time.Hour), s.org.ID)
55+
s.NoError(err)
56+
s.Equal(s.org.ID, token.OrganizationID.String())
57+
s.Equal("tokenStr", token.Description)
58+
s.NotNil(token.ExpiresAt)
59+
s.Nil(token.RevokedAt)
60+
})
61+
}
62+
63+
func (s *apiTokenTestSuite) TestRevoke() {
64+
ctx := context.Background()
65+
66+
s.T().Run("invalid org ID", func(t *testing.T) {
67+
err := s.APIToken.Revoke(ctx, "deadbeef", s.t1.ID.String())
68+
s.Error(err)
69+
s.True(biz.IsErrInvalidUUID(err))
70+
})
71+
72+
s.T().Run("invalid token ID", func(t *testing.T) {
73+
err := s.APIToken.Revoke(ctx, s.org.ID, "deadbeef")
74+
s.Error(err)
75+
s.True(biz.IsErrInvalidUUID(err))
76+
})
77+
78+
s.T().Run("token not found in org", func(t *testing.T) {
79+
err := s.APIToken.Revoke(ctx, s.org.ID, s.t3.ID.String())
80+
s.Error(err)
81+
s.True(biz.IsNotFound(err))
82+
})
83+
84+
s.T().Run("token can be revoked once", func(t *testing.T) {
85+
err := s.APIToken.Revoke(ctx, s.org.ID, s.t1.ID.String())
86+
s.NoError(err)
87+
tokens, err := s.APIToken.List(ctx, s.org.ID, true)
88+
s.NoError(err)
89+
s.Equal(s.t1.ID, tokens[0].ID)
90+
// It's revoked
91+
s.NotNil(tokens[0].RevokedAt)
92+
93+
// Can't be revoked twice
94+
err = s.APIToken.Revoke(ctx, s.org.ID, s.t1.ID.String())
95+
s.Error(err)
96+
s.True(biz.IsNotFound(err))
97+
})
98+
}
99+
100+
func (s *apiTokenTestSuite) TestList() {
101+
ctx := context.Background()
102+
s.T().Run("invalid org ID", func(t *testing.T) {
103+
tokens, err := s.APIToken.List(ctx, "deadbeef", false)
104+
s.Error(err)
105+
s.True(biz.IsErrInvalidUUID(err))
106+
s.Nil(tokens)
107+
})
108+
109+
s.T().Run("returns empty list", func(t *testing.T) {
110+
emptyOrg, err := s.Organization.Create(ctx, "org1")
111+
require.NoError(s.T(), err)
112+
tokens, err := s.APIToken.List(ctx, emptyOrg.ID, false)
113+
s.NoError(err)
114+
s.Len(tokens, 0)
115+
})
116+
117+
s.T().Run("returns the tokens for that org", func(t *testing.T) {
118+
var err error
119+
tokens, err := s.APIToken.List(ctx, s.org.ID, false)
120+
s.NoError(err)
121+
require.Len(s.T(), tokens, 2)
122+
s.Equal(s.t1.ID, tokens[0].ID)
123+
s.Equal(s.t2.ID, tokens[1].ID)
124+
125+
tokens, err = s.APIToken.List(ctx, s.org2.ID, false)
126+
s.NoError(err)
127+
require.Len(s.T(), tokens, 1)
128+
s.Equal(s.t3.ID, tokens[0].ID)
129+
})
130+
131+
s.T().Run("doesn't return revoked by default", func(t *testing.T) {
132+
// revoke one token
133+
err := s.APIToken.Revoke(ctx, s.org.ID, s.t1.ID.String())
134+
require.NoError(s.T(), err)
135+
tokens, err := s.APIToken.List(ctx, s.org.ID, false)
136+
s.NoError(err)
137+
require.Len(s.T(), tokens, 1)
138+
s.Equal(s.t2.ID, tokens[0].ID)
139+
})
140+
141+
s.T().Run("doesn't return revoked unless requested", func(t *testing.T) {
142+
// revoke one token
143+
tokens, err := s.APIToken.List(ctx, s.org.ID, true)
144+
s.NoError(err)
145+
require.Len(s.T(), tokens, 2)
146+
s.Equal(s.t1.ID, tokens[0].ID)
147+
s.Equal(s.t2.ID, tokens[1].ID)
148+
})
149+
}
150+
151+
func (s *apiTokenTestSuite) TestGeneratedJWT() {
152+
token, err := s.APIToken.Create(context.Background(), nil, toPtrDuration(24*time.Hour), s.org.ID)
153+
s.NoError(err)
154+
require.NotNil(s.T(), token)
155+
156+
claims := &apitoken.CustomClaims{}
157+
tokenInfo, err := jwt.ParseWithClaims(token.JWT, claims, func(_ *jwt.Token) (interface{}, error) {
158+
return []byte("test"), nil
159+
})
160+
161+
require.NoError(s.T(), err)
162+
s.True(tokenInfo.Valid)
163+
// The resulting JWT should have the same org, token ID and expiration time than
164+
// the reference in the DB
165+
s.Equal(token.OrganizationID.String(), claims.OrgID)
166+
s.Equal(token.ID.String(), claims.ID)
167+
s.Equal(token.ExpiresAt.Truncate(time.Second), claims.ExpiresAt.Truncate(time.Second))
168+
}
169+
170+
// Run the tests
171+
func TestAPITokenUseCase(t *testing.T) {
172+
suite.Run(t, new(apiTokenTestSuite))
173+
}
174+
175+
// Utility struct to hold the test suite
176+
type apiTokenTestSuite struct {
177+
testhelpers.UseCasesEachTestSuite
178+
org, org2 *biz.Organization
179+
t1, t2, t3 *biz.APIToken
180+
}
181+
182+
func (s *apiTokenTestSuite) SetupTest() {
183+
t := s.T()
184+
var err error
185+
assert := assert.New(s.T())
186+
ctx := context.Background()
187+
188+
s.TestingUseCases = testhelpers.NewTestingUseCases(t)
189+
s.org, err = s.Organization.Create(ctx, "org1")
190+
assert.NoError(err)
191+
s.org2, err = s.Organization.Create(ctx, "org2")
192+
assert.NoError(err)
193+
194+
// Create 2 tokens for org 1
195+
s.t1, err = s.APIToken.Create(ctx, nil, nil, s.org.ID)
196+
require.NoError(s.T(), err)
197+
s.t2, err = s.APIToken.Create(ctx, nil, nil, s.org.ID)
198+
require.NoError(s.T(), err)
199+
// and 1 token for org 2
200+
s.t3, err = s.APIToken.Create(ctx, nil, nil, s.org2.ID)
201+
require.NoError(s.T(), err)
202+
}

app/controlplane/internal/biz/biz.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ var ProviderSet = wire.NewSet(
3636
NewWorkflowRunExpirerUseCase,
3737
NewCASMappingUseCase,
3838
NewReferrerUseCase,
39+
NewAPITokenUseCase,
3940
wire.Struct(new(NewIntegrationUseCaseOpts), "*"),
4041
wire.Struct(new(NewUserUseCaseParams), "*"),
4142
)

app/controlplane/internal/biz/membership.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type MembershipRepo interface {
3636
FindByUser(ctx context.Context, userID uuid.UUID) ([]*Membership, error)
3737
FindByOrg(ctx context.Context, orgID uuid.UUID) ([]*Membership, error)
3838
FindByIDInUser(ctx context.Context, userID, ID uuid.UUID) (*Membership, error)
39+
FindByOrgAndUser(ctx context.Context, orgID, userID uuid.UUID) (*Membership, error)
3940
SetCurrent(ctx context.Context, ID uuid.UUID) (*Membership, error)
4041
Create(ctx context.Context, orgID, userID uuid.UUID, current bool) (*Membership, error)
4142
Delete(ctx context.Context, ID uuid.UUID) error

app/controlplane/internal/biz/organization.go

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -107,20 +107,10 @@ func (uc *OrganizationUseCase) Update(ctx context.Context, userID, orgID string,
107107
}
108108

109109
// Make sure that the organization exists and that the user is a member of it
110-
memberships, err := uc.membershipRepo.FindByUser(ctx, userUUID)
110+
membership, err := uc.membershipRepo.FindByOrgAndUser(ctx, orgUUID, userUUID)
111111
if err != nil {
112112
return nil, fmt.Errorf("failed to find memberships: %w", err)
113-
}
114-
115-
var found bool
116-
for _, m := range memberships {
117-
if m.OrganizationID == orgUUID {
118-
found = true
119-
break
120-
}
121-
}
122-
123-
if !found {
113+
} else if membership == nil {
124114
return nil, NewErrNotFound("organization")
125115
}
126116

app/controlplane/internal/biz/testhelpers/database.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ type TestingUseCases struct {
6666
CASMapping *biz.CASMappingUseCase
6767
OrgInvitation *biz.OrgInvitationUseCase
6868
Referrer *biz.ReferrerUseCase
69+
APIToken *biz.APITokenUseCase
6970
// Repositories that can be used for custom crafting of use-cases
7071
Repos *TestingRepos
7172
}

app/controlplane/internal/biz/testhelpers/wire_gen.go

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)