feat: App Keys API v2 (#10140)

# Which Problems Are Solved

This PR *partially* addresses #9450 . Specifically, it implements the
resource based API for app keys.

This PR, together with https://github.com/zitadel/zitadel/pull/10077
completes #9450 .

# How the Problems Are Solved

- Implementation of the following endpoints: `CreateApplicationKey`,
`DeleteApplicationKey`, `GetApplicationKey`, `ListApplicationKeys`
- `ListApplicationKeys` can filter by project, app or organization ID.
Sorting is also possible according to some criteria.
  - All endpoints use permissions V2

# TODO

 - [x] Deprecate old endpoints

# Additional Context

Closes #9450
This commit is contained in:
Marco A.
2025-07-02 09:34:19 +02:00
committed by GitHub
parent 64a03fba28
commit fce9e770ac
19 changed files with 1350 additions and 69 deletions

View File

@@ -1,6 +1,8 @@
package convert
import (
"strings"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
"github.com/zitadel/zitadel/internal/query"
@@ -58,3 +60,39 @@ func apiAuthMethodTypeToPb(methodType domain.APIAuthMethodType) app.APIAuthMetho
return app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC
}
}
func GetApplicationKeyQueriesRequestToDomain(orgID, projectID, appID string) ([]query.SearchQuery, error) {
var searchQueries []query.SearchQuery
orgID, projectID, appID = strings.TrimSpace(orgID), strings.TrimSpace(projectID), strings.TrimSpace(appID)
if orgID != "" {
resourceOwner, err := query.NewAuthNKeyResourceOwnerQuery(orgID)
if err != nil {
return nil, err
}
searchQueries = append(searchQueries, resourceOwner)
}
if projectID != "" {
aggregateID, err := query.NewAuthNKeyAggregateIDQuery(projectID)
if err != nil {
return nil, err
}
searchQueries = append(searchQueries, aggregateID)
}
if appID != "" {
objectID, err := query.NewAuthNKeyObjectIDQuery(appID)
if err != nil {
return nil, err
}
searchQueries = append(searchQueries, objectID)
}
return searchQueries, nil
}

View File

@@ -4,6 +4,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
@@ -147,3 +148,71 @@ func Test_apiAuthMethodTypeToPb(t *testing.T) {
})
}
}
func TestGetApplicationKeyQueriesRequestToDomain(t *testing.T) {
t.Parallel()
tt := []struct {
testName string
inputOrgID string
inputProjectID string
inputAppID string
expectedQueriesLength int
}{
{
testName: "all IDs provided",
inputOrgID: "org-1",
inputProjectID: "proj-1",
inputAppID: "app-1",
expectedQueriesLength: 3,
},
{
testName: "only org ID",
inputOrgID: "org-1",
inputProjectID: " ",
inputAppID: "",
expectedQueriesLength: 1,
},
{
testName: "only project ID",
inputOrgID: "",
inputProjectID: "proj-1",
inputAppID: " ",
expectedQueriesLength: 1,
},
{
testName: "only app ID",
inputOrgID: " ",
inputProjectID: "",
inputAppID: "app-1",
expectedQueriesLength: 1,
},
{
testName: "empty IDs",
inputOrgID: " ",
inputProjectID: " ",
inputAppID: " ",
expectedQueriesLength: 0,
},
{
testName: "with spaces",
inputOrgID: " org-1 ",
inputProjectID: " proj-1 ",
inputAppID: " app-1 ",
expectedQueriesLength: 3,
},
}
for _, tc := range tt {
t.Run(tc.testName, func(t *testing.T) {
t.Parallel()
// When
got, err := GetApplicationKeyQueriesRequestToDomain(tc.inputOrgID, tc.inputProjectID, tc.inputAppID)
// Then
require.NoError(t, err)
assert.Len(t, got, tc.expectedQueriesLength)
})
}
}

View File

@@ -2,6 +2,7 @@ package convert
import (
"net/url"
"strings"
"github.com/muhlemmer/gu"
"google.golang.org/protobuf/types/known/timestamppb"
@@ -9,6 +10,7 @@ import (
"github.com/zitadel/zitadel/internal/api/grpc/filter/v2"
"github.com/zitadel/zitadel/internal/config/systemdefaults"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors"
app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta"
@@ -163,3 +165,98 @@ func appQueryToModel(appQuery *app.ApplicationSearchFilter) (query.SearchQuery,
return nil, zerrors.ThrowInvalidArgument(nil, "CONV-z2mAGy", "List.Query.Invalid")
}
}
func CreateAPIClientKeyRequestToDomain(key *app.CreateApplicationKeyRequest) *domain.ApplicationKey {
return &domain.ApplicationKey{
ObjectRoot: models.ObjectRoot{
AggregateID: strings.TrimSpace(key.GetProjectId()),
},
ExpirationDate: key.GetExpirationDate().AsTime(),
Type: domain.AuthNKeyTypeJSON,
ApplicationID: strings.TrimSpace(key.GetAppId()),
}
}
func ListApplicationKeysRequestToDomain(sysDefaults systemdefaults.SystemDefaults, req *app.ListApplicationKeysRequest) (*query.AuthNKeySearchQueries, error) {
var queries []query.SearchQuery
switch req.GetResourceId().(type) {
case *app.ListApplicationKeysRequest_ApplicationId:
object, err := query.NewAuthNKeyObjectIDQuery(strings.TrimSpace(req.GetApplicationId()))
if err != nil {
return nil, err
}
queries = append(queries, object)
case *app.ListApplicationKeysRequest_OrganizationId:
resourceOwner, err := query.NewAuthNKeyResourceOwnerQuery(strings.TrimSpace(req.GetOrganizationId()))
if err != nil {
return nil, err
}
queries = append(queries, resourceOwner)
case *app.ListApplicationKeysRequest_ProjectId:
aggregate, err := query.NewAuthNKeyAggregateIDQuery(strings.TrimSpace(req.GetProjectId()))
if err != nil {
return nil, err
}
queries = append(queries, aggregate)
case nil:
default:
return nil, zerrors.ThrowInvalidArgument(nil, "CONV-t3ENme", "unexpected resource id")
}
offset, limit, asc, err := filter.PaginationPbToQuery(sysDefaults, req.GetPagination())
if err != nil {
return nil, err
}
return &query.AuthNKeySearchQueries{
SearchRequest: query.SearchRequest{
Offset: offset,
Limit: limit,
Asc: asc,
SortingColumn: appKeysSortingToColumn(req.GetSortingColumn()),
},
Queries: queries,
}, nil
}
func appKeysSortingToColumn(sortingCriteria app.ApplicationKeysSorting) query.Column {
switch sortingCriteria {
case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_PROJECT_ID:
return query.AuthNKeyColumnAggregateID
case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_CREATION_DATE:
return query.AuthNKeyColumnCreationDate
case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_EXPIRATION:
return query.AuthNKeyColumnExpiration
case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_ORGANIZATION_ID:
return query.AuthNKeyColumnResourceOwner
case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_TYPE:
return query.AuthNKeyColumnType
case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_APPLICATION_ID:
return query.AuthNKeyColumnObjectID
case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_ID:
fallthrough
default:
return query.AuthNKeyColumnID
}
}
func ApplicationKeysToPb(keys []*query.AuthNKey) []*app.ApplicationKey {
pbAppKeys := make([]*app.ApplicationKey, len(keys))
for i, k := range keys {
pbKey := &app.ApplicationKey{
Id: k.ID,
ApplicationId: k.ApplicationID,
ProjectId: k.AggregateID,
CreationDate: timestamppb.New(k.CreationDate),
OrganizationId: k.ResourceOwner,
ExpirationDate: timestamppb.New(k.Expiration),
}
pbAppKeys[i] = pbKey
}
return pbAppKeys
}

View File

@@ -518,3 +518,187 @@ func TestAppQueryToModel(t *testing.T) {
})
}
}
func TestListApplicationKeysRequestToDomain(t *testing.T) {
t.Parallel()
resourceOwnerQuery, err := query.NewAuthNKeyResourceOwnerQuery("org1")
require.NoError(t, err)
projectIDQuery, err := query.NewAuthNKeyAggregateIDQuery("project1")
require.NoError(t, err)
appIDQuery, err := query.NewAuthNKeyObjectIDQuery("app1")
require.NoError(t, err)
sysDefaults := systemdefaults.SystemDefaults{DefaultQueryLimit: 100, MaxQueryLimit: 150}
tt := []struct {
name string
req *app.ListApplicationKeysRequest
expectedResult *query.AuthNKeySearchQueries
expectedError error
}{
{
name: "invalid pagination limit",
req: &app.ListApplicationKeysRequest{
Pagination: &filter_pb_v2.PaginationRequest{Asc: true, Limit: uint32(sysDefaults.MaxQueryLimit + 1)},
},
expectedResult: nil,
expectedError: zerrors.ThrowInvalidArgumentf(fmt.Errorf("given: %d, allowed: %d", sysDefaults.MaxQueryLimit+1, sysDefaults.MaxQueryLimit), "QUERY-4M0fs", "Errors.Query.LimitExceeded"),
},
{
name: "empty request",
req: &app.ListApplicationKeysRequest{
Pagination: &filter_pb_v2.PaginationRequest{Asc: true},
},
expectedResult: &query.AuthNKeySearchQueries{
SearchRequest: query.SearchRequest{
Offset: 0,
Limit: 100,
Asc: true,
SortingColumn: query.AuthNKeyColumnID,
},
Queries: nil,
},
},
{
name: "only organization id",
req: &app.ListApplicationKeysRequest{
ResourceId: &app.ListApplicationKeysRequest_OrganizationId{OrganizationId: "org1"},
Pagination: &filter_pb_v2.PaginationRequest{Asc: true},
},
expectedResult: &query.AuthNKeySearchQueries{
SearchRequest: query.SearchRequest{
Offset: 0,
Limit: 100,
Asc: true,
SortingColumn: query.AuthNKeyColumnID,
},
Queries: []query.SearchQuery{
resourceOwnerQuery,
},
},
},
{
name: "only project id",
req: &app.ListApplicationKeysRequest{
ResourceId: &app.ListApplicationKeysRequest_ProjectId{ProjectId: "project1"},
Pagination: &filter_pb_v2.PaginationRequest{Asc: true},
},
expectedResult: &query.AuthNKeySearchQueries{
SearchRequest: query.SearchRequest{
Offset: 0,
Limit: 100,
Asc: true,
SortingColumn: query.AuthNKeyColumnID,
},
Queries: []query.SearchQuery{
projectIDQuery,
},
},
},
{
name: "only application id",
req: &app.ListApplicationKeysRequest{
ResourceId: &app.ListApplicationKeysRequest_ApplicationId{ApplicationId: "app1"},
Pagination: &filter_pb_v2.PaginationRequest{Asc: true},
},
expectedResult: &query.AuthNKeySearchQueries{
SearchRequest: query.SearchRequest{
Offset: 0,
Limit: 100,
Asc: true,
SortingColumn: query.AuthNKeyColumnID,
},
Queries: []query.SearchQuery{
appIDQuery,
},
},
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result, err := ListApplicationKeysRequestToDomain(sysDefaults, tc.req)
assert.Equal(t, tc.expectedError, err)
assert.Equal(t, tc.expectedResult, result)
})
}
}
func TestApplicationKeysToPb(t *testing.T) {
t.Parallel()
now := time.Now()
tt := []struct {
name string
input []*query.AuthNKey
expected []*app.ApplicationKey
}{
{
name: "multiple keys",
input: []*query.AuthNKey{
{
ID: "key1",
AggregateID: "project1",
ApplicationID: "app1",
CreationDate: now,
ResourceOwner: "org1",
Expiration: now.Add(24 * time.Hour),
Type: domain.AuthNKeyTypeJSON,
},
{
ID: "key2",
AggregateID: "project2",
ApplicationID: "app1",
CreationDate: now.Add(-time.Hour),
ResourceOwner: "org2",
Expiration: now.Add(48 * time.Hour),
Type: domain.AuthNKeyTypeNONE,
},
},
expected: []*app.ApplicationKey{
{
Id: "key1",
ApplicationId: "app1",
ProjectId: "project1",
CreationDate: timestamppb.New(now),
OrganizationId: "org1",
ExpirationDate: timestamppb.New(now.Add(24 * time.Hour)),
},
{
Id: "key2",
ApplicationId: "app1",
ProjectId: "project2",
CreationDate: timestamppb.New(now.Add(-time.Hour)),
OrganizationId: "org2",
ExpirationDate: timestamppb.New(now.Add(48 * time.Hour)),
},
},
},
{
name: "empty slice",
input: []*query.AuthNKey{},
expected: []*app.ApplicationKey{},
},
{
name: "nil input",
input: nil,
expected: []*app.ApplicationKey{},
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result := ApplicationKeysToPb(tc.input)
assert.Equal(t, tc.expected, result)
})
}
}