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
6 changes: 6 additions & 0 deletions apikey/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,9 @@ type TokenStore interface {
// RevokeToken records tokenID as revoked.
RevokeToken(ctx context.Context, tokenID string, revokedAt time.Time) error
}

// TokenMetadataLister lists token metadata without secret material.
type TokenMetadataLister interface {
// ListPrincipalTokenMetadata returns API-token metadata for principalID.
ListPrincipalTokenMetadata(ctx context.Context, principalID string) ([]TokenMetadata, error)
}
21 changes: 21 additions & 0 deletions apikey/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,27 @@ type IssuedToken struct {
IdentityLink authkit.LinkIdentityRequest
}

// TokenMetadata describes an API token without its secret material.
type TokenMetadata struct {
// ID is the stable lookup identifier embedded in the token.
ID string

// PrincipalID identifies the principal the token authenticates as.
PrincipalID string

// Name is an optional human-readable token label.
Name string

// ExpiresAt is the time after which the token must no longer authenticate.
ExpiresAt time.Time

// LastUsedAt records the last successful token verification time when known.
LastUsedAt *time.Time

// RevokedAt records when the token was revoked.
RevokedAt *time.Time
}

// StoredToken is the storage representation of an API token.
type StoredToken struct {
// ID is the stable lookup identifier embedded in the token.
Expand Down
3 changes: 3 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@ var (

// ErrProvisioningRuleNotFound indicates that a provisioning rule does not exist.
ErrProvisioningRuleNotFound = errors.New("authkit: provisioning rule not found")

// ErrPrincipalNotFound indicates that a principal does not exist.
ErrPrincipalNotFound = errors.New("authkit: principal not found")
)
237 changes: 237 additions & 0 deletions internal/storetest/storetest.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package storetest
import (
"context"
"crypto/sha256"
"sort"
"sync"
"testing"
"time"
Expand All @@ -17,6 +18,8 @@ import (

const (
concurrentProvisionAttempts = 8
listedTokenMetadataCount = 2
secondTokenRevokeOffset = 2 * time.Hour
testAction = "notes:read"
testProvider = "oidc"
testProvisioningRuleID = "engineering-readers"
Expand All @@ -28,9 +31,13 @@ const (
// Store is the complete storage surface exercised by Run.
type Store interface {
authkit.PrincipalCreator
authkit.PrincipalFinder
authkit.PrincipalLister
authkit.RoleCreator
authkit.RoleActionGranter
authkit.PrincipalRoleAssigner
authkit.PrincipalRoleUnassigner
authkit.PrincipalRoleAssignmentLister
authkit.PrincipalActionResolver
authkit.ProvisioningRuleCreator
authkit.ProvisioningRuleUpdater
Expand All @@ -41,6 +48,7 @@ type Store interface {
authkit.IdentityProvisioner
authkit.PrincipalResolver
apikey.TokenStore
apikey.TokenMetadataLister
oidc.ProviderTrustStore
}

Expand Down Expand Up @@ -90,6 +98,60 @@ func Run(t *testing.T, newStore func(t *testing.T) Store) {
assert.Empty(t, principal)
})

t.Run("find and list principals", func(t *testing.T) {
store := newStore(t)
first, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{
Kind: authkit.PrincipalKindUser,
DisplayName: testDisplayName,
Attributes: map[string]any{
"team": "platform",
},
})
require.NoError(t, err)
second, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{
Kind: authkit.PrincipalKindService,
DisplayName: "Deploy service",
})
require.NoError(t, err)

found, err := store.FindPrincipal(context.Background(), first.ID)
require.NoError(t, err)
assert.Equal(t, first, found)

first.Attributes["team"] = "changed"
found.Attributes["team"] = "changed from found"

foundAgain, err := store.FindPrincipal(context.Background(), first.ID)
require.NoError(t, err)
assert.Equal(t, "platform", foundAgain.Attributes["team"])

principals, err := store.ListPrincipals(context.Background())
require.NoError(t, err)
want := []authkit.Principal{foundAgain, second}
sort.Slice(want, func(i, j int) bool {
return want[i].ID < want[j].ID
})
assert.Equal(t, want, principals)

for i := range principals {
if principals[i].ID == first.ID {
principals[i].Attributes["team"] = "changed from list"
}
}
foundAfterListMutation, err := store.FindPrincipal(context.Background(), first.ID)
require.NoError(t, err)
assert.Equal(t, "platform", foundAfterListMutation.Attributes["team"])
})

t.Run("find principal missing behavior", func(t *testing.T) {
store := newStore(t)

found, err := store.FindPrincipal(context.Background(), "missing")

require.ErrorIs(t, err, authkit.ErrPrincipalNotFound)
assert.Empty(t, found)
})

t.Run("create role", func(t *testing.T) {
store := newStore(t)
req := roleRequest()
Expand Down Expand Up @@ -200,6 +262,41 @@ func Run(t *testing.T, newStore func(t *testing.T) Store) {
assert.Equal(t, []string{testAction}, actions)
})

t.Run("list and unassign principal roles", func(t *testing.T) {
store := newStore(t)
principal := createPrincipal(t, store)
createRole(t, store, "writers")
createRole(t, store, "readers")
for _, assignment := range []authkit.AssignPrincipalRoleRequest{
{PrincipalID: principal.ID, RoleID: "writers"},
{PrincipalID: principal.ID, RoleID: "readers"},
} {
require.NoError(t, store.AssignPrincipalRole(context.Background(), assignment))
}

assignments, err := store.ListPrincipalRoleAssignments(context.Background(), principal.ID)
require.NoError(t, err)
assert.Equal(t, []authkit.PrincipalRoleAssignment{
{PrincipalID: principal.ID, RoleID: "readers"},
{PrincipalID: principal.ID, RoleID: "writers"},
}, assignments)

require.NoError(t, store.UnassignPrincipalRole(context.Background(), authkit.UnassignPrincipalRoleRequest{
PrincipalID: principal.ID,
RoleID: "writers",
}))
require.NoError(t, store.UnassignPrincipalRole(context.Background(), authkit.UnassignPrincipalRoleRequest{
PrincipalID: principal.ID,
RoleID: "writers",
}))

assignments, err = store.ListPrincipalRoleAssignments(context.Background(), principal.ID)
require.NoError(t, err)
assert.Equal(t, []authkit.PrincipalRoleAssignment{
{PrincipalID: principal.ID, RoleID: "readers"},
}, assignments)
})

t.Run("assign principal role validates request", func(t *testing.T) {
store := newStore(t)
principal := createPrincipal(t, store)
Expand Down Expand Up @@ -244,6 +341,54 @@ func Run(t *testing.T, newStore func(t *testing.T) Store) {
}
})

t.Run("unassign and list principal roles validate request", func(t *testing.T) {
store := newStore(t)
principal := createPrincipal(t, store)
createRole(t, store, testRoleID)

tests := []struct {
name string
req authkit.UnassignPrincipalRoleRequest
}{
{
name: "missing principal ID",
req: authkit.UnassignPrincipalRoleRequest{
RoleID: testRoleID,
},
},
{
name: "missing role ID",
req: authkit.UnassignPrincipalRoleRequest{
PrincipalID: principal.ID,
},
},
{
name: "missing principal",
req: authkit.UnassignPrincipalRoleRequest{
PrincipalID: "missing",
RoleID: testRoleID,
},
},
{
name: "missing role",
req: authkit.UnassignPrincipalRoleRequest{
PrincipalID: principal.ID,
RoleID: "missing",
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Error(t, store.UnassignPrincipalRole(context.Background(), tt.req))
})
}

assignments, err := store.ListPrincipalRoleAssignments(context.Background(), "missing")
require.ErrorIs(t, err, authkit.ErrPrincipalNotFound)
assert.Nil(t, assignments)
})

t.Run("resolve principal actions returns distinct sorted actions", func(t *testing.T) {
store := newStore(t)
principal := createPrincipal(t, store)
Expand Down Expand Up @@ -929,6 +1074,39 @@ func Run(t *testing.T, newStore func(t *testing.T) Store) {
return runErr
},
},
{
name: "find principal",
run: func() error {
_, runErr := store.FindPrincipal(ctx, principal.ID)

return runErr
},
},
{
name: "list principals",
run: func() error {
_, runErr := store.ListPrincipals(ctx)

return runErr
},
},
{
name: "unassign principal role",
run: func() error {
return store.UnassignPrincipalRole(ctx, authkit.UnassignPrincipalRoleRequest{
PrincipalID: principal.ID,
RoleID: testRoleID,
})
},
},
{
name: "list principal role assignments",
run: func() error {
_, runErr := store.ListPrincipalRoleAssignments(ctx, principal.ID)

return runErr
},
},
{
name: "link identity",
run: func() error {
Expand Down Expand Up @@ -974,6 +1152,14 @@ func Run(t *testing.T, newStore func(t *testing.T) Store) {
return runErr
},
},
{
name: "list principal token metadata",
run: func() error {
_, runErr := store.ListPrincipalTokenMetadata(ctx, principal.ID)

return runErr
},
},
{
name: "update token last used",
run: func() error {
Expand Down Expand Up @@ -1104,6 +1290,53 @@ func Run(t *testing.T, newStore func(t *testing.T) Store) {
assert.Equal(t, revokedAt, *foundAgain.RevokedAt)
})

t.Run("list principal token metadata", func(t *testing.T) {
store := newStore(t)
first := createPrincipal(t, store)
second := createPrincipal(t, store)
now := fixedStoreTime()
firstToken := createToken(t, store, now, first.ID)
secondToken := tokenFixture(now, first.ID)
secondToken.ID = "token_2"
secondToken.Name = "second token"
require.NoError(t, store.CreateToken(context.Background(), secondToken))
otherToken := tokenFixture(now, second.ID)
otherToken.ID = "token_3"
require.NoError(t, store.CreateToken(context.Background(), otherToken))
usedAt := now.Add(time.Hour)
revokedAt := now.Add(secondTokenRevokeOffset)
require.NoError(t, store.UpdateTokenLastUsed(context.Background(), secondToken.ID, usedAt))
require.NoError(t, store.RevokeToken(context.Background(), secondToken.ID, revokedAt))

tokens, err := store.ListPrincipalTokenMetadata(context.Background(), first.ID)
require.NoError(t, err)
require.Len(t, tokens, listedTokenMetadataCount)
assert.Equal(t, apikey.TokenMetadata{
ID: firstToken.ID,
PrincipalID: first.ID,
Name: firstToken.Name,
ExpiresAt: firstToken.ExpiresAt,
}, tokens[0])
assert.Equal(t, secondToken.ID, tokens[1].ID)
assert.Equal(t, first.ID, tokens[1].PrincipalID)
assert.Equal(t, secondToken.Name, tokens[1].Name)
assert.Equal(t, secondToken.ExpiresAt, tokens[1].ExpiresAt)
require.NotNil(t, tokens[1].LastUsedAt)
require.NotNil(t, tokens[1].RevokedAt)
assert.Equal(t, usedAt, *tokens[1].LastUsedAt)
assert.Equal(t, revokedAt, *tokens[1].RevokedAt)

*tokens[1].LastUsedAt = now
*tokens[1].RevokedAt = now
listedAgain, err := store.ListPrincipalTokenMetadata(context.Background(), first.ID)
require.NoError(t, err)
require.Len(t, listedAgain, listedTokenMetadataCount)
require.NotNil(t, listedAgain[1].LastUsedAt)
require.NotNil(t, listedAgain[1].RevokedAt)
assert.Equal(t, usedAt, *listedAgain[1].LastUsedAt)
assert.Equal(t, revokedAt, *listedAgain[1].RevokedAt)
})

t.Run("token missing behavior", func(t *testing.T) {
store := newStore(t)
now := fixedStoreTime()
Expand All @@ -1118,6 +1351,10 @@ func Run(t *testing.T, newStore func(t *testing.T) Store) {
apikey.ErrTokenNotFound,
)
require.ErrorIs(t, store.RevokeToken(context.Background(), "missing", now), apikey.ErrTokenNotFound)

tokens, err := store.ListPrincipalTokenMetadata(context.Background(), "missing")
require.ErrorIs(t, err, authkit.ErrPrincipalNotFound)
assert.Nil(t, tokens)
})

t.Run("api token service integration", func(t *testing.T) {
Expand Down
Loading