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

@@ -0,0 +1,47 @@
package app
import (
"context"
"strings"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/grpc/app/v2beta/convert"
app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta"
)
func (s *Server) CreateApplicationKey(ctx context.Context, req *app.CreateApplicationKeyRequest) (*app.CreateApplicationKeyResponse, error) {
domainReq := convert.CreateAPIClientKeyRequestToDomain(req)
appKey, err := s.command.AddApplicationKey(ctx, domainReq, "")
if err != nil {
return nil, err
}
keyDetails, err := appKey.Detail()
if err != nil {
return nil, err
}
return &app.CreateApplicationKeyResponse{
Id: appKey.KeyID,
CreationDate: timestamppb.New(appKey.ChangeDate),
KeyDetails: keyDetails,
}, nil
}
func (s *Server) DeleteApplicationKey(ctx context.Context, req *app.DeleteApplicationKeyRequest) (*app.DeleteApplicationKeyResponse, error) {
deletionDetails, err := s.command.RemoveApplicationKey(ctx,
strings.TrimSpace(req.GetProjectId()),
strings.TrimSpace(req.GetApplicationId()),
strings.TrimSpace(req.GetId()),
strings.TrimSpace(req.GetOrganizationId()),
)
if err != nil {
return nil, err
}
return &app.DeleteApplicationKeyResponse{
DeletionDate: timestamppb.New(deletionDetails.EventDate),
}, nil
}

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)
})
}
}

View File

@@ -0,0 +1,206 @@
//go:build integration
package app_test
import (
"context"
"testing"
"time"
"github.com/brianvoe/gofakeit/v6"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta"
)
func TestCreateApplicationKey(t *testing.T) {
p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx)
createdApp := createAPIApp(t, IAMOwnerCtx, instance, p.GetId())
t.Parallel()
tt := []struct {
testName string
creationRequest *app.CreateApplicationKeyRequest
inputCtx context.Context
expectedErrorType codes.Code
}{
{
testName: "when app id is not found should return failed precondition",
inputCtx: IAMOwnerCtx,
creationRequest: &app.CreateApplicationKeyRequest{
ProjectId: p.GetId(),
AppId: gofakeit.UUID(),
ExpirationDate: timestamppb.New(time.Now().AddDate(0, 0, 1).UTC()),
},
expectedErrorType: codes.FailedPrecondition,
},
{
testName: "when CreateAPIApp request is valid should create app and return no error",
inputCtx: IAMOwnerCtx,
creationRequest: &app.CreateApplicationKeyRequest{
ProjectId: p.GetId(),
AppId: createdApp.GetAppId(),
ExpirationDate: timestamppb.New(time.Now().AddDate(0, 0, 1).UTC()),
},
},
// LoginUser
{
testName: "when user has no project.app.write permission for app key generation should return permission error",
inputCtx: LoginUserCtx,
creationRequest: &app.CreateApplicationKeyRequest{
ProjectId: p.GetId(),
AppId: createdApp.GetAppId(),
ExpirationDate: timestamppb.New(time.Now().AddDate(0, 0, 1).UTC()),
},
expectedErrorType: codes.PermissionDenied,
},
// OrgOwner
{
testName: "when user is OrgOwner app key request should succeed",
inputCtx: OrgOwnerCtx,
creationRequest: &app.CreateApplicationKeyRequest{
ProjectId: p.GetId(),
AppId: createdApp.GetAppId(),
ExpirationDate: timestamppb.New(time.Now().AddDate(0, 0, 1).UTC()),
},
},
// ProjectOwner
{
testName: "when user is ProjectOwner app key request should succeed",
inputCtx: projectOwnerCtx,
creationRequest: &app.CreateApplicationKeyRequest{
ProjectId: p.GetId(),
AppId: createdApp.GetAppId(),
ExpirationDate: timestamppb.New(time.Now().AddDate(0, 0, 1).UTC()),
},
},
}
for _, tc := range tt {
t.Run(tc.testName, func(t *testing.T) {
t.Parallel()
res, err := instance.Client.AppV2Beta.CreateApplicationKey(tc.inputCtx, tc.creationRequest)
require.Equal(t, tc.expectedErrorType, status.Code(err))
if tc.expectedErrorType == codes.OK {
assert.NotZero(t, res.GetId())
assert.NotZero(t, res.GetCreationDate())
assert.NotZero(t, res.GetKeyDetails())
}
})
}
}
func TestDeleteApplicationKey(t *testing.T) {
p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx)
createdApp := createAPIApp(t, IAMOwnerCtx, instance, p.GetId())
t.Parallel()
tt := []struct {
testName string
deletionRequest func(ttt *testing.T) *app.DeleteApplicationKeyRequest
inputCtx context.Context
expectedErrorType codes.Code
}{
{
testName: "when app key ID is not found should return not found error",
inputCtx: IAMOwnerCtx,
deletionRequest: func(ttt *testing.T) *app.DeleteApplicationKeyRequest {
return &app.DeleteApplicationKeyRequest{
Id: gofakeit.UUID(),
ProjectId: p.GetId(),
ApplicationId: createdApp.GetAppId(),
}
},
expectedErrorType: codes.NotFound,
},
{
testName: "when valid app key ID should delete successfully",
inputCtx: IAMOwnerCtx,
deletionRequest: func(ttt *testing.T) *app.DeleteApplicationKeyRequest {
createdAppKey := createAppKey(ttt, IAMOwnerCtx, instance, p.GetId(), createdApp.GetAppId(), time.Now().AddDate(0, 0, 1))
return &app.DeleteApplicationKeyRequest{
Id: createdAppKey.GetId(),
ProjectId: p.GetId(),
ApplicationId: createdApp.GetAppId(),
}
},
},
// LoginUser
{
testName: "when user has no project.app.write permission for app key deletion should return permission error",
inputCtx: LoginUserCtx,
deletionRequest: func(ttt *testing.T) *app.DeleteApplicationKeyRequest {
createdAppKey := createAppKey(ttt, IAMOwnerCtx, instance, p.GetId(), createdApp.GetAppId(), time.Now().AddDate(0, 0, 1))
return &app.DeleteApplicationKeyRequest{
Id: createdAppKey.GetId(),
ProjectId: p.GetId(),
ApplicationId: createdApp.GetAppId(),
}
},
expectedErrorType: codes.PermissionDenied,
},
// ProjectOwner
{
testName: "when user is OrgOwner API request should succeed",
inputCtx: projectOwnerCtx,
deletionRequest: func(ttt *testing.T) *app.DeleteApplicationKeyRequest {
createdAppKey := createAppKey(ttt, IAMOwnerCtx, instance, p.GetId(), createdApp.GetAppId(), time.Now().AddDate(0, 0, 1))
return &app.DeleteApplicationKeyRequest{
Id: createdAppKey.GetId(),
ProjectId: p.GetId(),
ApplicationId: createdApp.GetAppId(),
}
},
},
// OrganizationOwner
{
testName: "when user is OrgOwner app key deletion request should succeed",
inputCtx: OrgOwnerCtx,
deletionRequest: func(ttt *testing.T) *app.DeleteApplicationKeyRequest {
createdAppKey := createAppKey(ttt, IAMOwnerCtx, instance, p.GetId(), createdApp.GetAppId(), time.Now().AddDate(0, 0, 1))
return &app.DeleteApplicationKeyRequest{
Id: createdAppKey.GetId(),
ProjectId: p.GetId(),
ApplicationId: createdApp.GetAppId(),
}
},
},
}
for _, tc := range tt {
t.Run(tc.testName, func(t *testing.T) {
t.Parallel()
// Given
deletionReq := tc.deletionRequest(t)
// When
res, err := instance.Client.AppV2Beta.DeleteApplicationKey(tc.inputCtx, deletionReq)
// Then
require.Equal(t, tc.expectedErrorType, status.Code(err))
if tc.expectedErrorType == codes.OK {
assert.NotEmpty(t, res.GetDeletionDate())
}
})
}
}

View File

@@ -1,6 +1,6 @@
//go:build integration
package instance_test
package app_test
import (
"context"
@@ -653,9 +653,9 @@ func TestUpdateApplication_WithDifferentPermissions(t *testing.T) {
})
require.Nil(t, appNameChangeErr)
appForAPIConfigChangeForProjectOwner := createAPIApp(t, p.GetId())
appForAPIConfigChangeForOrgOwner := createAPIApp(t, p.GetId())
appForAPIConfigChangeForLoginUser := createAPIApp(t, p.GetId())
appForAPIConfigChangeForProjectOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId())
appForAPIConfigChangeForOrgOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId())
appForAPIConfigChangeForLoginUser := createAPIApp(t, IAMOwnerCtx, instance, p.GetId())
appForOIDCConfigChangeForProjectOwner := createOIDCApp(t, baseURI, p.GetId())
appForOIDCConfigChangeForOrgOwner := createOIDCApp(t, baseURI, p.GetId())
@@ -914,9 +914,9 @@ func TestDeleteApplication(t *testing.T) {
func TestDeleteApplication_WithDifferentPermissions(t *testing.T) {
p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx)
appToDeleteForLoginUser := createAPIApp(t, p.GetId())
appToDeleteForProjectOwner := createAPIApp(t, p.GetId())
appToDeleteForOrgOwner := createAPIApp(t, p.GetId())
appToDeleteForLoginUser := createAPIApp(t, IAMOwnerCtx, instance, p.GetId())
appToDeleteForProjectOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId())
appToDeleteForOrgOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId())
t.Parallel()
tt := []struct {
@@ -1035,9 +1035,9 @@ func TestDeactivateApplication(t *testing.T) {
func TestDeactivateApplication_WithDifferentPermissions(t *testing.T) {
p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx)
appToDeactivateForLoginUser := createAPIApp(t, p.GetId())
appToDeactivateForPrjectOwner := createAPIApp(t, p.GetId())
appToDeactivateForOrgOwner := createAPIApp(t, p.GetId())
appToDeactivateForLoginUser := createAPIApp(t, IAMOwnerCtx, instance, p.GetId())
appToDeactivateForPrjectOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId())
appToDeactivateForOrgOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId())
t.Parallel()
@@ -1162,13 +1162,13 @@ func TestReactivateApplication(t *testing.T) {
func TestReactivateApplication_WithDifferentPermissions(t *testing.T) {
p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx)
appToReactivateForLoginUser := createAPIApp(t, p.GetId())
appToReactivateForLoginUser := createAPIApp(t, IAMOwnerCtx, instance, p.GetId())
deactivateApp(t, appToReactivateForLoginUser, p.GetId())
appToReactivateForProjectOwner := createAPIApp(t, p.GetId())
appToReactivateForProjectOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId())
deactivateApp(t, appToReactivateForProjectOwner, p.GetId())
appToReactivateForOrgOwner := createAPIApp(t, p.GetId())
appToReactivateForOrgOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId())
deactivateApp(t, appToReactivateForOrgOwner, p.GetId())
t.Parallel()
@@ -1342,9 +1342,9 @@ func TestRegenerateClientSecret(t *testing.T) {
func TestRegenerateClientSecret_WithDifferentPermissions(t *testing.T) {
p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx)
apiAppToRegenForLoginUser := createAPIApp(t, p.GetId())
apiAppToRegenForProjectOwner := createAPIApp(t, p.GetId())
apiAppToRegenForOrgOwner := createAPIApp(t, p.GetId())
apiAppToRegenForLoginUser := createAPIApp(t, IAMOwnerCtx, instance, p.GetId())
apiAppToRegenForProjectOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId())
apiAppToRegenForOrgOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId())
oidcAppToRegenForLoginUser := createOIDCApp(t, baseURI, p.GetId())
oidcAppToRegenForProjectOwner := createOIDCApp(t, baseURI, p.GetId())

View File

@@ -1,6 +1,6 @@
//go:build integration
package instance_test
package app_test
import (
"context"
@@ -165,9 +165,9 @@ func TestListApplications(t *testing.T) {
t.Parallel()
createdApiApp, apiAppName := createAPIAppWithName(t, p.GetId())
createdApiApp, apiAppName := createAPIAppWithName(t, IAMOwnerCtx, instance, p.GetId())
createdDeactivatedApiApp, deactivatedApiAppName := createAPIAppWithName(t, p.GetId())
createdDeactivatedApiApp, deactivatedApiAppName := createAPIAppWithName(t, IAMOwnerCtx, instance, p.GetId())
deactivateApp(t, createdDeactivatedApiApp, p.GetId())
_, createdSAMLApp, samlAppName := createSAMLAppWithName(t, gofakeit.URL(), p.GetId())
@@ -573,3 +573,248 @@ func TestListApplications_WithPermissionV2(t *testing.T) {
})
}
}
func TestGetApplicationKey(t *testing.T) {
p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx)
createdApiApp := createAPIApp(t, IAMOwnerCtx, instance, p.GetId())
createdAppKey := createAppKey(t, IAMOwnerCtx, instance, p.GetId(), createdApiApp.GetAppId(), time.Now().AddDate(0, 0, 1))
t.Parallel()
tt := []struct {
testName string
inputRequest *app.GetApplicationKeyRequest
inputCtx context.Context
expectedErrorType codes.Code
expectedAppKeyID string
}{
{
testName: "when unknown app ID should return not found error",
inputCtx: IAMOwnerCtx,
inputRequest: &app.GetApplicationKeyRequest{
Id: gofakeit.Sentence(2),
},
expectedErrorType: codes.NotFound,
},
{
testName: "when user has no permission should return membership not found error",
inputCtx: NoPermissionCtx,
inputRequest: &app.GetApplicationKeyRequest{
Id: createdAppKey.GetId(),
},
expectedErrorType: codes.NotFound,
},
{
testName: "when providing API app ID should return valid API app result",
inputCtx: projectOwnerCtx,
inputRequest: &app.GetApplicationKeyRequest{
Id: createdAppKey.GetId(),
},
expectedAppKeyID: createdAppKey.GetId(),
},
{
testName: "when user is OrgOwner should return request key",
inputCtx: OrgOwnerCtx,
inputRequest: &app.GetApplicationKeyRequest{
Id: createdAppKey.GetId(),
ProjectId: p.GetId(),
},
expectedAppKeyID: createdAppKey.GetId(),
},
{
testName: "when user is IAMOwner should return request key",
inputCtx: OrgOwnerCtx,
inputRequest: &app.GetApplicationKeyRequest{
Id: createdAppKey.GetId(),
OrganizationId: instance.DefaultOrg.GetId(),
},
expectedAppKeyID: createdAppKey.GetId(),
},
}
for _, tc := range tt {
t.Run(tc.testName, func(t *testing.T) {
t.Parallel()
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 30*time.Second)
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
// When
res, err := instance.Client.AppV2Beta.GetApplicationKey(tc.inputCtx, tc.inputRequest)
// Then
require.Equal(t, tc.expectedErrorType, status.Code(err))
if tc.expectedErrorType == codes.OK {
assert.Equal(t, tc.expectedAppKeyID, res.GetId())
assert.NotEmpty(t, res.GetCreationDate())
assert.NotEmpty(t, res.GetExpirationDate())
}
}, retryDuration, tick)
})
}
}
func TestListApplicationKeys(t *testing.T) {
p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx)
createdApiApp1 := createAPIApp(t, IAMOwnerCtx, instance, p.GetId())
createdApiApp2 := createAPIApp(t, IAMOwnerCtx, instance, p.GetId())
tomorrow := time.Now().AddDate(0, 0, 1)
in2Days := tomorrow.AddDate(0, 0, 1)
in3Days := in2Days.AddDate(0, 0, 1)
appKey1 := createAppKey(t, IAMOwnerCtx, instance, p.GetId(), createdApiApp1.GetAppId(), in2Days)
appKey2 := createAppKey(t, IAMOwnerCtx, instance, p.GetId(), createdApiApp1.GetAppId(), in3Days)
appKey3 := createAppKey(t, IAMOwnerCtx, instance, p.GetId(), createdApiApp1.GetAppId(), tomorrow)
appKey4 := createAppKey(t, IAMOwnerCtx, instance, p.GetId(), createdApiApp2.GetAppId(), tomorrow)
t.Parallel()
tt := []struct {
testName string
inputRequest *app.ListApplicationKeysRequest
deps func() (projectID, applicationID, organizationID string)
inputCtx context.Context
expectedErrorType codes.Code
expectedAppKeysIDs []string
}{
{
testName: "when sorting by expiration date should return keys sorted by expiration date ascending",
inputCtx: LoginUserCtx,
inputRequest: &app.ListApplicationKeysRequest{
ResourceId: &app.ListApplicationKeysRequest_ProjectId{ProjectId: p.GetId()},
Pagination: &filter.PaginationRequest{Asc: true},
SortingColumn: app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_EXPIRATION,
},
expectedAppKeysIDs: []string{appKey3.GetId(), appKey4.GetId(), appKey1.GetId(), appKey2.GetId()},
},
{
testName: "when sorting by creation date should return keys sorted by creation date descending",
inputCtx: IAMOwnerCtx,
inputRequest: &app.ListApplicationKeysRequest{
ResourceId: &app.ListApplicationKeysRequest_ProjectId{ProjectId: p.GetId()},
SortingColumn: app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_CREATION_DATE,
},
expectedAppKeysIDs: []string{appKey4.GetId(), appKey3.GetId(), appKey2.GetId(), appKey1.GetId()},
},
{
testName: "when filtering by app ID should return keys matching app ID sorted by ID",
inputCtx: projectOwnerCtx,
inputRequest: &app.ListApplicationKeysRequest{
Pagination: &filter.PaginationRequest{Asc: true},
ResourceId: &app.ListApplicationKeysRequest_ApplicationId{ApplicationId: createdApiApp1.GetAppId()},
},
expectedAppKeysIDs: []string{appKey1.GetId(), appKey2.GetId(), appKey3.GetId()},
},
}
for _, tc := range tt {
t.Run(tc.testName, func(t *testing.T) {
t.Parallel()
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 5*time.Second)
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
// When
res, err := instance.Client.AppV2Beta.ListApplicationKeys(tc.inputCtx, tc.inputRequest)
// Then
require.Equal(ttt, tc.expectedErrorType, status.Code(err))
if tc.expectedErrorType == codes.OK {
require.Len(ttt, res.GetKeys(), len(tc.expectedAppKeysIDs))
for i, k := range res.GetKeys() {
assert.Equal(ttt, tc.expectedAppKeysIDs[i], k.GetId())
}
}
}, retryDuration, tick)
})
}
}
func TestListApplicationKeys_WithPermissionV2(t *testing.T) {
ensureFeaturePermissionV2Enabled(t, instancePermissionV2)
iamOwnerCtx := instancePermissionV2.WithAuthorization(context.Background(), integration.UserTypeIAMOwner)
loginUserCtx := instancePermissionV2.WithAuthorization(context.Background(), integration.UserTypeLogin)
p, projectOwnerCtx := getProjectAndProjectContext(t, instancePermissionV2, iamOwnerCtx)
createdApiApp1 := createAPIApp(t, iamOwnerCtx, instancePermissionV2, p.GetId())
createdApiApp2 := createAPIApp(t, iamOwnerCtx, instancePermissionV2, p.GetId())
tomorrow := time.Now().AddDate(0, 0, 1)
in2Days := tomorrow.AddDate(0, 0, 1)
in3Days := in2Days.AddDate(0, 0, 1)
appKey1 := createAppKey(t, iamOwnerCtx, instancePermissionV2, p.GetId(), createdApiApp1.GetAppId(), in2Days)
appKey2 := createAppKey(t, iamOwnerCtx, instancePermissionV2, p.GetId(), createdApiApp1.GetAppId(), in3Days)
appKey3 := createAppKey(t, iamOwnerCtx, instancePermissionV2, p.GetId(), createdApiApp1.GetAppId(), tomorrow)
appKey4 := createAppKey(t, iamOwnerCtx, instancePermissionV2, p.GetId(), createdApiApp2.GetAppId(), tomorrow)
t.Parallel()
tt := []struct {
testName string
inputRequest *app.ListApplicationKeysRequest
deps func() (projectID, applicationID, organizationID string)
inputCtx context.Context
expectedErrorType codes.Code
expectedAppKeysIDs []string
}{
{
testName: "when sorting by expiration date should return keys sorted by expiration date ascending",
inputCtx: loginUserCtx,
inputRequest: &app.ListApplicationKeysRequest{
Pagination: &filter.PaginationRequest{Asc: true},
SortingColumn: app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_EXPIRATION,
},
expectedAppKeysIDs: []string{appKey3.GetId(), appKey4.GetId(), appKey1.GetId(), appKey2.GetId()},
},
{
testName: "when sorting by creation date should return keys sorted by creation date descending",
inputCtx: iamOwnerCtx,
inputRequest: &app.ListApplicationKeysRequest{
SortingColumn: app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_CREATION_DATE,
},
expectedAppKeysIDs: []string{appKey4.GetId(), appKey3.GetId(), appKey2.GetId(), appKey1.GetId()},
},
{
testName: "when filtering by app ID should return keys matching app ID sorted by ID",
inputCtx: projectOwnerCtx,
inputRequest: &app.ListApplicationKeysRequest{
Pagination: &filter.PaginationRequest{Asc: true},
ResourceId: &app.ListApplicationKeysRequest_ApplicationId{ApplicationId: createdApiApp1.GetAppId()},
},
expectedAppKeysIDs: []string{appKey1.GetId(), appKey2.GetId(), appKey3.GetId()},
},
}
for _, tc := range tt {
t.Run(tc.testName, func(t *testing.T) {
// t.Parallel()
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 5*time.Second)
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
// When
res, err := instancePermissionV2.Client.AppV2Beta.ListApplicationKeys(tc.inputCtx, tc.inputRequest)
// Then
require.Equal(ttt, tc.expectedErrorType, status.Code(err))
if tc.expectedErrorType == codes.OK {
require.Len(ttt, res.GetKeys(), len(tc.expectedAppKeysIDs))
for i, k := range res.GetKeys() {
assert.Equal(ttt, tc.expectedAppKeysIDs[i], k.GetId())
}
}
}, retryDuration, tick)
})
}
}

View File

@@ -1,6 +1,6 @@
//go:build integration
package instance_test
package app_test
import (
"context"
@@ -13,6 +13,7 @@ import (
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/integration"
app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta"
@@ -150,14 +151,14 @@ func createOIDCApp(t *testing.T, baseURI, projctID string) *app.CreateApplicatio
return app
}
func createAPIAppWithName(t *testing.T, projectID string) (*app.CreateApplicationResponse, string) {
func createAPIAppWithName(t *testing.T, ctx context.Context, inst *integration.Instance, projectID string) (*app.CreateApplicationResponse, string) {
appName := gofakeit.AppName()
reqForAPIAppCreation := &app.CreateApplicationRequest_ApiRequest{
ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT},
}
appForAPIConfigChange, appAPIConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{
appForAPIConfigChange, appAPIConfigChangeErr := inst.Client.AppV2Beta.CreateApplication(ctx, &app.CreateApplicationRequest{
ProjectId: projectID,
Name: appName,
CreationRequestType: reqForAPIAppCreation,
@@ -167,8 +168,8 @@ func createAPIAppWithName(t *testing.T, projectID string) (*app.CreateApplicatio
return appForAPIConfigChange, appName
}
func createAPIApp(t *testing.T, projectID string) *app.CreateApplicationResponse {
res, _ := createAPIAppWithName(t, projectID)
func createAPIApp(t *testing.T, ctx context.Context, inst *integration.Instance, projectID string) *app.CreateApplicationResponse {
res, _ := createAPIAppWithName(t, ctx, inst, projectID)
return res
}
@@ -203,3 +204,17 @@ func ensureFeaturePermissionV2Enabled(t *testing.T, instance *integration.Instan
assert.True(tt, f.PermissionCheckV2.GetEnabled())
}, retryDuration, tick, "timed out waiting for ensuring instance feature")
}
func createAppKey(t *testing.T, ctx context.Context, inst *integration.Instance, projectID, appID string, expirationDate time.Time) *app.CreateApplicationKeyResponse {
res, err := inst.Client.AppV2Beta.CreateApplicationKey(ctx,
&app.CreateApplicationKeyRequest{
AppId: appID,
ProjectId: projectID,
ExpirationDate: timestamppb.New(expirationDate.UTC()),
},
)
require.Nil(t, err)
return res
}

View File

@@ -2,9 +2,13 @@ package app
import (
"context"
"strings"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/grpc/app/v2beta/convert"
filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2"
"github.com/zitadel/zitadel/internal/query"
app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta"
)
@@ -35,3 +39,38 @@ func (s *Server) ListApplications(ctx context.Context, req *app.ListApplications
Pagination: filter.QueryToPaginationPb(queries.SearchRequest, res.SearchResponse),
}, nil
}
func (s *Server) GetApplicationKey(ctx context.Context, req *app.GetApplicationKeyRequest) (*app.GetApplicationKeyResponse, error) {
queries, err := convert.GetApplicationKeyQueriesRequestToDomain(req.GetOrganizationId(), req.GetProjectId(), req.GetApplicationId())
if err != nil {
return nil, err
}
key, err := s.query.GetAuthNKeyByIDWithPermission(ctx, true, strings.TrimSpace(req.GetId()), s.checkPermission, queries...)
if err != nil {
return nil, err
}
return &app.GetApplicationKeyResponse{
Id: key.ID,
CreationDate: timestamppb.New(key.CreationDate),
ExpirationDate: timestamppb.New(key.Expiration),
}, nil
}
func (s *Server) ListApplicationKeys(ctx context.Context, req *app.ListApplicationKeysRequest) (*app.ListApplicationKeysResponse, error) {
queries, err := convert.ListApplicationKeysRequestToDomain(s.systemDefaults, req)
if err != nil {
return nil, err
}
res, err := s.query.SearchAuthNKeys(ctx, queries, query.JoinFilterUnspecified, s.checkPermission)
if err != nil {
return nil, err
}
return &app.ListApplicationKeysResponse{
Keys: convert.ApplicationKeysToPb(res.AuthNKeys),
Pagination: filter.QueryToPaginationPb(queries.SearchRequest, res.SearchResponse),
}, nil
}

View File

@@ -177,7 +177,7 @@ func AddAPIClientKeyRequestToDomain(key *mgmt_pb.AddAppKeyRequest) *domain.Appli
}
func ListAPIClientKeysRequestToQuery(ctx context.Context, req *mgmt_pb.ListAppKeysRequest) (*query.AuthNKeySearchQueries, error) {
resourcOwner, err := query.NewAuthNKeyResourceOwnerQuery(authz.GetCtxData(ctx).OrgID)
resourceOwner, err := query.NewAuthNKeyResourceOwnerQuery(authz.GetCtxData(ctx).OrgID)
if err != nil {
return nil, err
}
@@ -197,7 +197,7 @@ func ListAPIClientKeysRequestToQuery(ctx context.Context, req *mgmt_pb.ListAppKe
Asc: asc,
},
Queries: []query.SearchQuery{
resourcOwner,
resourceOwner,
projectID,
appID,
},

View File

@@ -38,6 +38,11 @@ func (c *Commands) AddApplicationKey(ctx context.Context, key *domain.Applicatio
if err != nil {
return nil, err
}
if resourceOwner == "" {
resourceOwner = application.ResourceOwner
}
if !application.State.Exists() {
return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-sak25", "Errors.Project.App.NotFound")
}
@@ -59,6 +64,10 @@ func (c *Commands) addApplicationKey(ctx context.Context, key *domain.Applicatio
return nil, err
}
if err := c.checkPermissionUpdateApplication(ctx, keyWriteModel.ResourceOwner, keyWriteModel.AggregateID); err != nil {
return nil, err
}
if !keyWriteModel.KeysAllowed {
return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Dff54", "Errors.Project.App.AuthMethodNoPrivateKeyJWT")
}
@@ -110,6 +119,10 @@ func (c *Commands) RemoveApplicationKey(ctx context.Context, projectID, applicat
return nil, zerrors.ThrowNotFound(nil, "COMMAND-4m77G", "Errors.Project.App.Key.NotFound")
}
if err := c.checkPermissionUpdateApplication(ctx, keyWriteModel.ResourceOwner, keyWriteModel.AggregateID); err != nil {
return nil, err
}
pushedEvents, err := c.eventstore.Push(ctx, project.NewApplicationKeyRemovedEvent(ctx, ProjectAggregateFromWriteModel(&keyWriteModel.WriteModel), keyID))
if err != nil {
return nil, err

View File

@@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/zitadel/zitadel/internal/domain"
permissionmock "github.com/zitadel/zitadel/internal/domain/mock"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
"github.com/zitadel/zitadel/internal/id"
@@ -17,9 +18,10 @@ import (
func TestCommandSide_AddAPIApplicationKey(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
idGenerator id.Generator
keySize int
eventstore func(*testing.T) *eventstore.Eventstore
idGenerator id.Generator
keySize int
permissionCheckMock domain.PermissionCheck
}
type args struct {
ctx context.Context
@@ -39,9 +41,8 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) {
{
name: "no aggregateid, invalid argument error",
fields: fields{
eventstore: eventstoreExpect(
t,
),
eventstore: expectEventstore(),
permissionCheckMock: permissionmock.MockPermissionCheckOK(),
},
args: args{
ctx: context.Background(),
@@ -57,9 +58,8 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) {
{
name: "no appid, invalid argument error",
fields: fields{
eventstore: eventstoreExpect(
t,
),
eventstore: expectEventstore(),
permissionCheckMock: permissionmock.MockPermissionCheckOK(),
},
args: args{
ctx: context.Background(),
@@ -77,10 +77,8 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) {
{
name: "app not existing, not found error",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(),
),
eventstore: expectEventstore(expectFilter()),
permissionCheckMock: permissionmock.MockPermissionCheckOK(),
},
args: args{
ctx: context.Background(),
@@ -97,10 +95,9 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) {
},
},
{
name: "create key not allowed, precondition error",
name: "create key not allowed, precondition error 1",
fields: fields{
eventstore: eventstoreExpect(
t,
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
project.NewApplicationAddedEvent(context.Background(),
@@ -121,7 +118,8 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) {
),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"),
permissionCheckMock: permissionmock.MockPermissionCheckOK(),
},
args: args{
ctx: context.Background(),
@@ -138,10 +136,9 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) {
},
},
{
name: "create key not allowed, precondition error",
name: "permission check failed",
fields: fields{
eventstore: eventstoreExpect(
t,
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
project.NewApplicationAddedEvent(context.Background(),
@@ -162,8 +159,9 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) {
),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"),
keySize: 10,
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"),
keySize: 10,
permissionCheckMock: permissionmock.MockPermissionCheckErr(zerrors.ThrowPermissionDenied(nil, "mock.err", "mock permission check failed")),
},
args: args{
ctx: context.Background(),
@@ -175,6 +173,47 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) {
},
resourceOwner: "org1",
},
res: res{
err: zerrors.IsPermissionDenied,
},
},
{
name: "create key not allowed, precondition error 2",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
),
),
),
expectFilter(
eventFromEventPusher(
project.NewAPIConfigAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"client1@project",
"secret",
domain.APIAuthMethodTypeBasic),
),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"),
keySize: 10,
permissionCheckMock: permissionmock.MockPermissionCheckOK(),
},
args: args{
ctx: context.Background(),
key: &domain.ApplicationKey{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
},
ApplicationID: "app1",
},
},
res: res{
err: zerrors.IsPreconditionFailed,
},
@@ -183,9 +222,10 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore,
eventstore: tt.fields.eventstore(t),
idGenerator: tt.fields.idGenerator,
applicationKeySize: tt.fields.keySize,
checkPermission: tt.fields.permissionCheckMock,
}
got, err := r.AddApplicationKey(tt.args.ctx, tt.args.key, tt.args.resourceOwner)
if tt.res.err == nil {

View File

@@ -0,0 +1,22 @@
package permissionmock
import (
"golang.org/x/net/context"
"github.com/zitadel/zitadel/internal/domain"
)
// MockPermissionCheckErr returns a permission check function that will fail
// and return the input error
func MockPermissionCheckErr(err error) domain.PermissionCheck {
return func(_ context.Context, _, _, _ string) error {
return err
}
}
// MockPermissionCheckOK returns a permission check function that will succeed
func MockPermissionCheckOK() domain.PermissionCheck {
return func(_ context.Context, _, _, _ string) (err error) {
return nil
}
}

View File

@@ -98,6 +98,7 @@ type AuthNKey struct {
ChangeDate time.Time
ResourceOwner string
Sequence uint64
ApplicationID string
Expiration time.Time
Type domain.AuthNKeyType
@@ -222,6 +223,19 @@ func (q *Queries) SearchAuthNKeysData(ctx context.Context, queries *AuthNKeySear
return authNKeys, err
}
func (q *Queries) GetAuthNKeyByIDWithPermission(ctx context.Context, shouldTriggerBulk bool, id string, permissionCheck domain.PermissionCheck, queries ...SearchQuery) (*AuthNKey, error) {
key, err := q.GetAuthNKeyByID(ctx, shouldTriggerBulk, id, queries...)
if err != nil {
return nil, err
}
if err := appCheckPermission(ctx, key.ResourceOwner, key.AggregateID, permissionCheck); err != nil {
return nil, err
}
return key, nil
}
func (q *Queries) GetAuthNKeyByID(ctx context.Context, shouldTriggerBulk bool, id string, queries ...SearchQuery) (key *AuthNKey, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
@@ -330,6 +344,7 @@ func prepareAuthNKeysQuery() (sq.SelectBuilder, func(rows *sql.Rows) (*AuthNKeys
AuthNKeyColumnSequence.identifier(),
AuthNKeyColumnExpiration.identifier(),
AuthNKeyColumnType.identifier(),
AuthNKeyColumnObjectID.identifier(),
countColumn.identifier(),
).From(authNKeyTable.identifier()).
PlaceholderFormat(sq.Dollar)
@@ -348,6 +363,7 @@ func prepareAuthNKeysQuery() (sq.SelectBuilder, func(rows *sql.Rows) (*AuthNKeys
&authNKey.Sequence,
&authNKey.Expiration,
&authNKey.Type,
&authNKey.ApplicationID,
&count,
)
if err != nil {

View File

@@ -26,6 +26,7 @@ var (
` projections.authn_keys2.sequence,` +
` projections.authn_keys2.expiration,` +
` projections.authn_keys2.type,` +
` projections.authn_keys2.object_id,` +
` COUNT(*) OVER ()` +
` FROM projections.authn_keys2`
prepareAuthNKeysCols = []string{
@@ -37,6 +38,7 @@ var (
"sequence",
"expiration",
"type",
"object_id",
"count",
}
@@ -129,6 +131,7 @@ func Test_AuthNKeyPrepares(t *testing.T) {
uint64(20211109),
testNow,
1,
"app1",
},
},
),
@@ -147,6 +150,7 @@ func Test_AuthNKeyPrepares(t *testing.T) {
Sequence: 20211109,
Expiration: testNow,
Type: domain.AuthNKeyTypeJSON,
ApplicationID: "app1",
},
},
},
@@ -168,6 +172,7 @@ func Test_AuthNKeyPrepares(t *testing.T) {
uint64(20211109),
testNow,
1,
"app1",
},
{
"id-2",
@@ -178,6 +183,7 @@ func Test_AuthNKeyPrepares(t *testing.T) {
uint64(20211109),
testNow,
1,
"app1",
},
},
),
@@ -196,6 +202,7 @@ func Test_AuthNKeyPrepares(t *testing.T) {
Sequence: 20211109,
Expiration: testNow,
Type: domain.AuthNKeyTypeJSON,
ApplicationID: "app1",
},
{
ID: "id-2",
@@ -206,6 +213,7 @@ func Test_AuthNKeyPrepares(t *testing.T) {
Sequence: 20211109,
Expiration: testNow,
Type: domain.AuthNKeyTypeJSON,
ApplicationID: "app1",
},
},
},

View File

@@ -92,3 +92,30 @@ message ApplicationNameQuery {
}
];
}
enum ApplicationKeysSorting {
APPLICATION_KEYS_SORT_BY_ID = 0;
APPLICATION_KEYS_SORT_BY_PROJECT_ID = 1;
APPLICATION_KEYS_SORT_BY_APPLICATION_ID = 2;
APPLICATION_KEYS_SORT_BY_CREATION_DATE = 3;
APPLICATION_KEYS_SORT_BY_ORGANIZATION_ID = 4;
APPLICATION_KEYS_SORT_BY_EXPIRATION = 5;
APPLICATION_KEYS_SORT_BY_TYPE = 6;
}
message ApplicationKey {
string id = 1;
string application_id = 2;
string project_id = 3;
google.protobuf.Timestamp creation_date = 4 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"2024-12-18T07:50:47.492Z\"";
}
];
string organization_id = 5;
google.protobuf.Timestamp expiration_date = 6 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"2024-12-18T07:50:47.492Z\"";
}
];
}

View File

@@ -114,8 +114,6 @@ service AppService {
//
// Create an application. The application can be OIDC, API or SAML type, based on the input.
//
// The user needs to have project.app.write permission
//
// Required permissions:
// - project.app.write
rpc CreateApplication(CreateApplicationRequest) returns (CreateApplicationResponse) {
@@ -145,8 +143,6 @@ service AppService {
// Changes the configuration of an OIDC, API or SAML type application, as well as
// the application name, based on the input provided.
//
// The user needs to have project.app.write permission
//
// Required permissions:
// - project.app.write
rpc UpdateApplication(UpdateApplicationRequest) returns (UpdateApplicationResponse) {
@@ -175,8 +171,6 @@ service AppService {
//
// Retrieves the application matching the provided ID.
//
// The user needs to have project.app.read permission
//
// Required permissions:
// - project.app.read
rpc GetApplication(GetApplicationRequest) returns (GetApplicationResponse) {
@@ -203,9 +197,7 @@ service AppService {
// Delete Application
//
// Deletes the application belonging to the input project and matching the provided
// application ID
//
// The user needs to have project.app.delete permission
// application ID.
//
// Required permissions:
// - project.app.delete
@@ -233,9 +225,7 @@ service AppService {
// Deactivate Application
//
// Deactivates the application belonging to the input project and matching the provided
// application ID
//
// The user needs to have project.app.write permission
// application ID.
//
// Required permissions:
// - project.app.write
@@ -264,9 +254,7 @@ service AppService {
// Reactivate Application
//
// Reactivates the application belonging to the input project and matching the provided
// application ID
//
// The user needs to have project.app.write permission
// application ID.
//
// Required permissions:
// - project.app.write
@@ -297,8 +285,6 @@ service AppService {
//
// Regenerates the client secret of an API or OIDC application that belongs to the input project.
//
// The user needs to have project.app.write permission
//
// Required permissions:
// - project.app.write
rpc RegenerateClientSecret(RegenerateClientSecretRequest) returns (RegenerateClientSecretResponse) {
@@ -331,8 +317,6 @@ service AppService {
// The result can be sorted by app id, name, creation date, change date or state. It can also
// be filtered by app state, app type and app name.
//
// The user needs to have project.app.read permission
//
// Required permissions:
// - project.app.read
rpc ListApplications(ListApplicationsRequest) returns (ListApplicationsResponse) {
@@ -356,6 +340,129 @@ service AppService {
}
};
}
// Create Application Key
//
// Create a new application key, which is used to authorize an API application.
//
// Key details are returned in the response. They must be stored safely, as it will not
// be possible to retrieve them again.
//
// Required permissions:
// - `project.app.write`
rpc CreateApplicationKey(CreateApplicationKeyRequest) returns (CreateApplicationKeyResponse) {
option (google.api.http) = {
post: "/v2beta/application_keys"
body: "*"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "200";
value: {
description: "The created application key";
}
};
};
}
// Delete Application Key
//
// Deletes an application key matching the provided ID.
//
// Organization ID is not mandatory, but helps with filtering/performance.
//
// The deletion time is returned in response message.
//
// Required permissions:
// - `project.app.write`
rpc DeleteApplicationKey(DeleteApplicationKeyRequest) returns (DeleteApplicationKeyResponse) {
option (google.api.http) = {
delete: "/v2beta/application_keys/{id}"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "200";
value: {
description: "The time of deletion.";
}
};
};
}
// Get Application Key
//
// Retrieves the application key matching the provided ID.
//
// Specifying a project, organization and app ID is optional but help with filtering/performance.
//
// Required permissions:
// - project.app.read
rpc GetApplicationKey(GetApplicationKeyRequest) returns (GetApplicationKeyResponse) {
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "200";
value: {
description: "The fetched app key.";
}
};
};
option (google.api.http) = {
get: "/v2beta/application_keys/{id}"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
}
// List Application Keys
//
// Returns a list of application keys matching the input parameters.
//
// The result can be sorted by id, aggregate, creation date, expiration date, resource owner or type.
// It can also be filtered by app, project or organization ID.
//
// Required permissions:
// - project.app.read
rpc ListApplicationKeys(ListApplicationKeysRequest) returns (ListApplicationKeysResponse) {
option (google.api.http) = {
post: "/v2beta/application_keys/search"
body: "*"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "200";
value: {
description: "The matching applications";
}
};
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
}
}
message CreateApplicationRequest {
@@ -786,3 +893,102 @@ message ListApplicationsResponse {
// Contains the total number of apps matching the query and the applied limit.
zitadel.filter.v2.PaginationResponse pagination = 2;
}
message CreateApplicationKeyRequest {
string app_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}];
string project_id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}];
// The date the key will expire
google.protobuf.Timestamp expiration_date = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"2519-04-01T08:45:00.000000Z\"";
description: "The date the key will expire";
}
];
}
message CreateApplicationKeyResponse {
string id = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"28746028909593987\"";
}
];
// The timestamp of the app creation.
google.protobuf.Timestamp creation_date = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"2024-12-18T07:50:47.492Z\"";
}
];
bytes key_details = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"eyJ0eXBlIjoiYXBwbGljYXRpb24iLCJrZXlJZCI6IjIwMjcxMDE4NjYyMjcxNDExMyIsImtleSI6Ii0tLS0tQkVHSU4gUlNBIFBSSVZBVEUgS0VZLS0tLS1cbk1JSUVvd0lCQUFLQ0FRRUFuMUxyNStTV0pGRllURU1kaXQ2U0dNY0E2Yks5dG0xMmhlcm55V0wrZm9PWnA3eEVcbk9wcmsvWE81QVplSU5NY0x0ZVhxckJlK1NPdVVNMFpLU2xCMHFTNzNjVStDVTVMTGoycVB0UzhNOFI0N3BGdFhcbjJXRTFJNjNhZHB1N01TejA2SXduQ2lyNnJYOTVPQ2ZneHA3VU1Dd0pSTUZmYXJqdjVBRXY3NXpsSS9lYUV6bUJcbkxKWU1xanZFRmZoN2x3M2lPT3VsWW9kNjNpN3RDNWl5czNlYjNLZW4yWU0rN1FSbXB2dE5qcTJMVmlIMnkrUGJcbk9ESlI3MU9ib05TYVJDNTZDUFpWVytoWDByYXI3VzMwUjI2eGtIQ09oSytQbUpSeGtOY0g1VTdja0xXMEw0WEVcbnNNZkVUSmszeDR3Q0psbisxbElXUzkrNmw0R1E2TWRzWURyOU5RSURBUUFCQW9JQkFCSkx6WGQxMHFBZEQwekNcbnNGUFFOMnJNLzVmV3hONThONDR0YWF6QXg0VHp5K050UlZDTmxScGQvYkxuR2VjbHJIeVpDSmYycWcxcHNEMHJcbkowRGRlR2d0VXBFYWxsYk9scjNEZVBsUGkrYnNsK0RKOUk2c0VSUWwxTjZtQjVzZ0ZJZllBR3UwZjlFSXdIem9cblozR25yNnBRaEVmM0JPUVdsTVhVTlJNSksyOHp3M2E1L01nRmtKVUZUSTUzeXFwbGRtZ2hLajRZR1hLRk1LUGhcbkV3RkxrRncwK2s3K0xuSjFQNGp1ZVd1RXo3WlAyaFpvUWxCcXdSajVyTG9QZ05RbUU4UytFVDRuczlUYzByOFFcbnFyaHlacDZBczJrTDhGTytCZnF3SVpDZnpnWHN2cC9PLzRaSHIzVTB2Ymp3UW1sSzdVSm42U0J6T2hpWFpNU0lcbk5Wc0V5VUVDZ1lFQTFEaktkRGo3NTM1MWQzdlRNQlRFd2JSQ3hoUVZOdENFMnMwVUw4ckJQZ1I0K1dlblNUWmFcbnprWUprcEV0bE54VGxzYnN1Y0RTUXZqeWRYYk5nSHFBeDYzMm1vdTVkak9lR0VTUDFWVGtUdElsZFZQZWszQWxcbjVYbkpQa1dqWGVyVVJZNm5KeUQ5UWhlREx3MVp4NEFYVzNHWURiTFkrT05XV0VKUlJaQUloNjBDZ1lFQXdEQ2xcbnc1MHc4dkcvbEJ4RzNSYW9FaHdLOWNna1VXOHk2T25DekNwcEtjOEZUUmY1VE5iWjl5TzNXUmdYajhkeHRCakFcbkl5VGlzYk9NQk1VaFZKUUtGZHRQaDhoVDBwRkRjeE9ndzY0aHBtYzhyY2RTbXVKNzlYSVRTaHUySjA0N0UvNFZcbnJOTThpWVk5ZGR3VGdGUUlsdFNZL0l0RnFxWERmdjhqK1dVY25La0NnWUVBaENOUU80bDNuNjRucWR2WnBTaHBcblVrclJBTkJrWFJyOGZkZ1BaNnFSSS9KWStNSEhjVmg4dGM3NkN0NkdTUmZlbkJVRU5LeVF2czZPK1FDZCtBOU9cbnZBWGZkRjduZldlcVdtWG1RT2g0dDNNMWk1WkxFZlpVUWt2UU9BdllLcFFhMDZ4OCsyb1pCdHZvL0pVTmY2Q0xcbjZvNFNKUVZrLzZOZGtkckpDODBnNG9rQ2dZQkZsNWYrbkVYa1F0dWZVeG5wNXRGWE5XWldsM0ZuTjMvVXpRaW5cbmkxZm5OcnB4cnhPcjJrUzA4KzdwU1FzSEdpNDNDNXRQWG9UajJlTUN1eXNWaUVHYXBuNUc2YWhJb0NjdlhWVWlcblprUnpFQUR0NERZdU5ZS3pYdXBUTkhPaUNmYmtoMlhyM2RXVzZ0QUloSGRmU1k2T3AwNzZhNmYvWWVUSGNMWGpcbkVkVHBlUUtCZ0FPdnBqcDQ4TzRUWEZkU0JLSnYya005OHVhUjlSQURtdGxTWHd2cTlyQkhTV084NFk4bzE0L1Bcbkl1UmxUOHhROGRYKzhMR21UUCtjcUtiOFFRQ1grQk1YUWxMSEVtWnpnb0xFa0pGMUVIMm4vZEZ5bngxS3prdFNcbm9UZUdsRzZhbXhVOVh4eW9RVFlEVGJCbERwc2FZUlFBZ2FUQzM3UVZRUjhmK1ZoRzFHSFFcbi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tXG4iLCJhcHBJZCI6IjIwMjcwNjM5ODgxMzg4MDU3NyIsImNsaWVudElkIjoiMjAyNzA2Mzk4ODEzOTQ2MTEzQG15dGVzdHByb2plY3QifQ==\"";
}
];
}
message DeleteApplicationKeyRequest {
string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}];
string project_id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}];
string application_id = 3 [(validate.rules).string = {min_len: 1, max_len: 200}];
string organization_id = 4 [(validate.rules).string = {max_len: 200}];
}
message DeleteApplicationKeyResponse {
google.protobuf.Timestamp deletion_date = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"2025-01-23T10:34:18.051Z\"";
}
];
}
message GetApplicationKeyRequest {
string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}];
string project_id = 2 [(validate.rules).string = {max_len: 200}];
string application_id = 3 [(validate.rules).string = {max_len: 200}];
string organization_id = 4 [(validate.rules).string = {max_len: 200}];
}
message GetApplicationKeyResponse {
string id = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"69629023906488334\"";
}
];
google.protobuf.Timestamp creation_date = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"2025-01-23T10:34:18.051Z\"";
}
];
// the date a key will expire
google.protobuf.Timestamp expiration_date = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "the date a key will expire";
example: "\"3019-04-01T08:45:00.000000Z\"";
}
];
}
message ListApplicationKeysRequest {
// Pagination and sorting.
zitadel.filter.v2.PaginationRequest pagination = 1;
ApplicationKeysSorting sorting_column = 2;
oneof resource_id {
string application_id = 3 [(validate.rules).string = {min_len: 1; max_len: 200}];
string project_id = 4 [(validate.rules).string = {min_len: 1; max_len: 200}];
string organization_id = 5 [(validate.rules).string = {min_len: 1; max_len: 200}];
}
}
message ListApplicationKeysResponse {
repeated ApplicationKey keys = 1;
// Contains the total number of app keys matching the query and the applied limit.
zitadel.filter.v2.PaginationResponse pagination = 2;
}

View File

@@ -3709,6 +3709,7 @@ service ManagementService {
};
}
// Deprecated: Use [GetApplicationKey](/apis/resources/application_service_v2/application-service-get-application-key.api.mdx) instead to get an application key
rpc GetAppKey(GetAppKeyRequest) returns (GetAppKeyResponse) {
option (google.api.http) = {
get: "/projects/{project_id}/apps/{app_id}/keys/{key_id}"
@@ -3731,9 +3732,11 @@ service ManagementService {
required: false;
};
};
deprecated: true;
};
}
// Deprecated: Use [ListApplicationKeys](/apis/resources/application_service_v2/application-service-list-application-keys.api.mdx) instead to list application keys
rpc ListAppKeys(ListAppKeysRequest) returns (ListAppKeysResponse) {
option (google.api.http) = {
post: "/projects/{project_id}/apps/{app_id}/keys/_search"
@@ -3760,6 +3763,8 @@ service ManagementService {
};
}
// Deprecated: Use [CreateApplicationKey](/apis/resources/application_service_v2/application-service-create-application-key.api.mdx) instead to
// create an application key
rpc AddAppKey(AddAppKeyRequest) returns (AddAppKeyResponse){
option (google.api.http) = {
post: "/projects/{project_id}/apps/{app_id}/keys"
@@ -3783,9 +3788,12 @@ service ManagementService {
required: false;
};
};
deprecated: true;
};
}
// Deprecated: Use [DeleteApplicationKey](/apis/resources/application_service_v2/application-service-delete-application-key.api.mdx) instead to
// delete an application key
rpc RemoveAppKey(RemoveAppKeyRequest) returns (RemoveAppKeyResponse) {
option (google.api.http) = {
delete: "/projects/{project_id}/apps/{app_id}/keys/{key_id}"
@@ -3808,6 +3816,7 @@ service ManagementService {
required: false;
};
};
deprecated: true;
};
}