mirror of
https://github.com/zitadel/zitadel.git
synced 2025-10-21 13:49:14 +00:00
feat: token introspection, api clients and auth method private_key_jwt (#1276)
* introspect * testingapplication key * date * client keys * fix client keys * fix client keys * access tokens only for users * AuthMethodPrivateKeyJWT * client keys * set introspection info correctly * managae apis * update oidc pkg * cleanup * merge msater * set current sequence in migration * set current sequence in migration * set current sequence in migration * Apply suggestions from code review Co-authored-by: Fabi <38692350+fgerschwiler@users.noreply.github.com> * DeleteAuthNKeysByObjectID * ensure authn keys uptodate * update oidc version * merge master * merge master Co-authored-by: Fabi <38692350+fgerschwiler@users.noreply.github.com>
This commit is contained in:
62
internal/project/model/api_config.go
Normal file
62
internal/project/model/api_config.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/caos/logging"
|
||||
|
||||
"github.com/caos/zitadel/internal/crypto"
|
||||
"github.com/caos/zitadel/internal/errors"
|
||||
es_models "github.com/caos/zitadel/internal/eventstore/models"
|
||||
"github.com/caos/zitadel/internal/id"
|
||||
)
|
||||
|
||||
type APIConfig struct {
|
||||
es_models.ObjectRoot
|
||||
AppID string
|
||||
ClientID string
|
||||
ClientSecret *crypto.CryptoValue
|
||||
ClientSecretString string
|
||||
AuthMethodType APIAuthMethodType
|
||||
ClientKeys []*ClientKey
|
||||
}
|
||||
|
||||
type APIAuthMethodType int32
|
||||
|
||||
const (
|
||||
APIAuthMethodTypeBasic APIAuthMethodType = iota
|
||||
APIAuthMethodTypePrivateKeyJWT
|
||||
)
|
||||
|
||||
func (c *APIConfig) IsValid() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
//ClientID random_number@projectname (eg. 495894098234@zitadel)
|
||||
func (c *APIConfig) GenerateNewClientID(idGenerator id.Generator, project *Project) error {
|
||||
rndID, err := idGenerator.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.ClientID = fmt.Sprintf("%v@%v", rndID, strings.ReplaceAll(strings.ToLower(project.Name), " ", "_"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *APIConfig) GenerateClientSecretIfNeeded(generator crypto.Generator) (string, error) {
|
||||
if c.AuthMethodType == APIAuthMethodTypeBasic {
|
||||
return c.GenerateNewClientSecret(generator)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (c *APIConfig) GenerateNewClientSecret(generator crypto.Generator) (string, error) {
|
||||
cryptoValue, stringSecret, err := crypto.NewCode(generator)
|
||||
if err != nil {
|
||||
logging.Log("MODEL-ADvd2").OnError(err).Error("unable to create client secret")
|
||||
return "", errors.ThrowInternal(err, "MODEL-dsvr43", "Errors.Project.CouldNotGenerateClientSecret")
|
||||
}
|
||||
c.ClientSecret = cryptoValue
|
||||
return stringSecret, nil
|
||||
}
|
@@ -1,8 +1,9 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
es_models "github.com/caos/zitadel/internal/eventstore/models"
|
||||
"github.com/golang/protobuf/ptypes/timestamp"
|
||||
|
||||
es_models "github.com/caos/zitadel/internal/eventstore/models"
|
||||
)
|
||||
|
||||
type Application struct {
|
||||
@@ -13,6 +14,7 @@ type Application struct {
|
||||
Name string
|
||||
Type AppType
|
||||
OIDCConfig *OIDCConfig
|
||||
APIConfig *APIConfig
|
||||
}
|
||||
type ApplicationChanges struct {
|
||||
Changes []*ApplicationChange
|
||||
@@ -42,6 +44,7 @@ const (
|
||||
AppTypeUnspecified AppType = iota
|
||||
AppTypeOIDC
|
||||
AppTypeSAML
|
||||
AppTypeAPI
|
||||
)
|
||||
|
||||
func NewApplication(projectID, appID string) *Application {
|
||||
@@ -58,5 +61,20 @@ func (a *Application) IsValid(includeConfig bool) bool {
|
||||
if a.Type == AppTypeOIDC && !a.OIDCConfig.IsValid() {
|
||||
return false
|
||||
}
|
||||
if a.Type == AppTypeAPI && !a.APIConfig.IsValid() {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *Application) GetKey(keyID string) (int, *ClientKey) {
|
||||
if a.OIDCConfig == nil {
|
||||
return -1, nil
|
||||
}
|
||||
for i, k := range a.OIDCConfig.ClientKeys {
|
||||
if k.KeyID == keyID {
|
||||
return i, k
|
||||
}
|
||||
}
|
||||
return -1, nil
|
||||
}
|
||||
|
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/caos/zitadel/internal/errors"
|
||||
es_models "github.com/caos/zitadel/internal/eventstore/models"
|
||||
"github.com/caos/zitadel/internal/id"
|
||||
key_model "github.com/caos/zitadel/internal/key/model"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -40,6 +41,7 @@ type OIDCConfig struct {
|
||||
IDTokenRoleAssertion bool
|
||||
IDTokenUserinfoAssertion bool
|
||||
ClockSkew time.Duration
|
||||
ClientKeys []*ClientKey
|
||||
}
|
||||
|
||||
type OIDCVersion int32
|
||||
@@ -78,6 +80,7 @@ const (
|
||||
OIDCAuthMethodTypeBasic OIDCAuthMethodType = iota
|
||||
OIDCAuthMethodTypePost
|
||||
OIDCAuthMethodTypeNone
|
||||
OIDCAuthMethodTypePrivateKeyJWT
|
||||
)
|
||||
|
||||
type Compliance struct {
|
||||
@@ -92,6 +95,27 @@ const (
|
||||
OIDCTokenTypeJWT
|
||||
)
|
||||
|
||||
type ClientKey struct {
|
||||
es_models.ObjectRoot
|
||||
|
||||
ApplicationID string
|
||||
ClientID string
|
||||
KeyID string
|
||||
Type key_model.AuthNKeyType
|
||||
ExpirationDate time.Time
|
||||
PrivateKey []byte
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
es_models.ObjectRoot
|
||||
|
||||
TokenID string
|
||||
ClientID string
|
||||
Audience []string
|
||||
Expiration time.Time
|
||||
Scopes []string
|
||||
}
|
||||
|
||||
func (c *OIDCConfig) IsValid() bool {
|
||||
grantTypes := c.getRequiredGrantTypes()
|
||||
for _, grantType := range grantTypes {
|
||||
@@ -115,10 +139,10 @@ func (c *OIDCConfig) GenerateNewClientID(idGenerator id.Generator, project *Proj
|
||||
}
|
||||
|
||||
func (c *OIDCConfig) GenerateClientSecretIfNeeded(generator crypto.Generator) (string, error) {
|
||||
if c.AuthMethodType == OIDCAuthMethodTypeNone {
|
||||
return "", nil
|
||||
if c.AuthMethodType == OIDCAuthMethodTypeBasic || c.AuthMethodType == OIDCAuthMethodTypePost {
|
||||
return c.GenerateNewClientSecret(generator)
|
||||
}
|
||||
return c.GenerateNewClientSecret(generator)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (c *OIDCConfig) GenerateNewClientSecret(generator crypto.Generator) (string, error) {
|
||||
|
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/caos/logging"
|
||||
"github.com/golang/protobuf/ptypes"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
es_models "github.com/caos/zitadel/internal/eventstore/models"
|
||||
es_sdk "github.com/caos/zitadel/internal/eventstore/sdk"
|
||||
"github.com/caos/zitadel/internal/id"
|
||||
key_model "github.com/caos/zitadel/internal/key/model"
|
||||
proj_model "github.com/caos/zitadel/internal/project/model"
|
||||
"github.com/caos/zitadel/internal/project/repository/eventsourcing/model"
|
||||
"github.com/caos/zitadel/internal/telemetry/tracing"
|
||||
@@ -30,10 +32,11 @@ const (
|
||||
|
||||
type ProjectEventstore struct {
|
||||
es_int.Eventstore
|
||||
projectCache *ProjectCache
|
||||
passwordAlg crypto.HashAlgorithm
|
||||
pwGenerator crypto.Generator
|
||||
idGenerator id.Generator
|
||||
projectCache *ProjectCache
|
||||
passwordAlg crypto.HashAlgorithm
|
||||
pwGenerator crypto.Generator
|
||||
idGenerator id.Generator
|
||||
ClientKeySize int
|
||||
}
|
||||
|
||||
type ProjectConfig struct {
|
||||
@@ -49,11 +52,12 @@ func StartProject(conf ProjectConfig, systemDefaults sd.SystemDefaults) (*Projec
|
||||
passwordAlg := crypto.NewBCrypt(systemDefaults.SecretGenerators.PasswordSaltCost)
|
||||
pwGenerator := crypto.NewHashGenerator(systemDefaults.SecretGenerators.ClientSecretGenerator, passwordAlg)
|
||||
return &ProjectEventstore{
|
||||
Eventstore: conf.Eventstore,
|
||||
projectCache: projectCache,
|
||||
passwordAlg: passwordAlg,
|
||||
pwGenerator: pwGenerator,
|
||||
idGenerator: id.SonyFlakeGenerator,
|
||||
Eventstore: conf.Eventstore,
|
||||
projectCache: projectCache,
|
||||
passwordAlg: passwordAlg,
|
||||
pwGenerator: pwGenerator,
|
||||
idGenerator: id.SonyFlakeGenerator,
|
||||
ClientKeySize: int(systemDefaults.SecretGenerators.ClientKeySize),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -513,15 +517,14 @@ func (es *ProjectEventstore) AddApplication(ctx context.Context, app *proj_model
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := es.idGenerator.Next()
|
||||
app.AppID, err = es.idGenerator.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
app.AppID = id
|
||||
|
||||
var stringPw string
|
||||
if app.OIDCConfig != nil {
|
||||
app.OIDCConfig.AppID = id
|
||||
app.OIDCConfig.AppID = app.AppID
|
||||
err := app.OIDCConfig.GenerateNewClientID(es.idGenerator, existingProject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -531,6 +534,17 @@ func (es *ProjectEventstore) AddApplication(ctx context.Context, app *proj_model
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if app.APIConfig != nil {
|
||||
app.APIConfig.AppID = app.AppID
|
||||
err := app.APIConfig.GenerateNewClientID(es.idGenerator, existingProject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stringPw, err = app.APIConfig.GenerateClientSecretIfNeeded(es.pwGenerator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
repoProject := model.ProjectFromModel(existingProject)
|
||||
repoApp := model.AppFromModel(app)
|
||||
|
||||
@@ -542,8 +556,13 @@ func (es *ProjectEventstore) AddApplication(ctx context.Context, app *proj_model
|
||||
es.projectCache.cacheProject(repoProject)
|
||||
if _, a := model.GetApplication(repoProject.Applications, app.AppID); a != nil {
|
||||
converted := model.AppToModel(a)
|
||||
converted.OIDCConfig.ClientSecretString = stringPw
|
||||
converted.OIDCConfig.FillCompliance()
|
||||
if converted.OIDCConfig != nil {
|
||||
converted.OIDCConfig.ClientSecretString = stringPw
|
||||
converted.OIDCConfig.FillCompliance()
|
||||
}
|
||||
if converted.APIConfig != nil {
|
||||
converted.APIConfig.ClientSecretString = stringPw
|
||||
}
|
||||
return converted, nil
|
||||
}
|
||||
return nil, caos_errs.ThrowInternal(nil, "EVENT-GvPct", "Errors.Internal")
|
||||
@@ -748,6 +767,36 @@ func (es *ProjectEventstore) ChangeOIDCConfig(ctx context.Context, config *proj_
|
||||
return nil, caos_errs.ThrowInternal(nil, "EVENT-dk87s", "Errors.Internal")
|
||||
}
|
||||
|
||||
func (es *ProjectEventstore) ChangeAPIConfig(ctx context.Context, config *proj_model.APIConfig) (*proj_model.APIConfig, error) {
|
||||
if config == nil || !config.IsValid() {
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "EVENT-SDg54", "Errors.Project.APIConfigInvalid")
|
||||
}
|
||||
existingProject, err := es.ProjectByID(ctx, config.AggregateID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var app *proj_model.Application
|
||||
if _, app = existingProject.GetApp(config.AppID); app == nil {
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "EVENT-Rgu63", "Errors.Project.AppNotExisting")
|
||||
}
|
||||
if app.Type != proj_model.AppTypeAPI {
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "EVENT-RHj63", "Errors.Project.AppIsNotAPI")
|
||||
}
|
||||
repoProject := model.ProjectFromModel(existingProject)
|
||||
repoConfig := model.APIConfigFromModel(config)
|
||||
|
||||
projectAggregate := APIConfigChangedAggregate(es.Eventstore.AggregateCreator(), repoProject, repoConfig)
|
||||
err = es_sdk.Push(ctx, es.PushAggregates, repoProject.AppendEvents, projectAggregate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
es.projectCache.cacheProject(repoProject)
|
||||
if _, a := model.GetApplication(repoProject.Applications, app.AppID); a != nil {
|
||||
return model.APIConfigToModel(a.APIConfig), nil
|
||||
}
|
||||
return nil, caos_errs.ThrowInternal(nil, "EVENT-aebn5", "Errors.Internal")
|
||||
}
|
||||
|
||||
func (es *ProjectEventstore) ChangeOIDCConfigSecret(ctx context.Context, projectID, appID string) (*proj_model.OIDCConfig, error) {
|
||||
if appID == "" {
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "EVENT-7ue34", "Errors.Project.OIDCConfigInvalid")
|
||||
@@ -763,8 +812,8 @@ func (es *ProjectEventstore) ChangeOIDCConfigSecret(ctx context.Context, project
|
||||
if app.Type != proj_model.AppTypeOIDC {
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "EVENT-dile4", "Errors.Project.AppIsNotOIDC")
|
||||
}
|
||||
if app.OIDCConfig.AuthMethodType == proj_model.OIDCAuthMethodTypeNone {
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "EVENT-GDrg2", "Errors.Project.OIDCAuthMethodNoneSecret")
|
||||
if app.OIDCConfig.AuthMethodType == proj_model.OIDCAuthMethodTypeNone || app.OIDCConfig.AuthMethodType == proj_model.OIDCAuthMethodTypePrivateKeyJWT {
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "EVENT-GDrg2", "Errors.Project.OIDCAuthMethodNoSecret")
|
||||
}
|
||||
repoProject := model.ProjectFromModel(existingProject)
|
||||
|
||||
@@ -789,6 +838,47 @@ func (es *ProjectEventstore) ChangeOIDCConfigSecret(ctx context.Context, project
|
||||
return nil, caos_errs.ThrowInternal(nil, "EVENT-dk87s", "Errors.Internal")
|
||||
}
|
||||
|
||||
func (es *ProjectEventstore) ChangeAPIConfigSecret(ctx context.Context, projectID, appID string) (*proj_model.APIConfig, error) {
|
||||
if appID == "" {
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "EVENT-sdfb3", "Errors.Project.APIConfigInvalid")
|
||||
}
|
||||
existingProject, err := es.ProjectByID(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var app *proj_model.Application
|
||||
if _, app = existingProject.GetApp(appID); app == nil {
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "EVENT-ADbg3", "Errors.Project.AppNotExisting")
|
||||
}
|
||||
if app.Type != proj_model.AppTypeAPI {
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "EVENT-Ntwqw", "Errors.Project.AppIsNotAPI")
|
||||
}
|
||||
if app.APIConfig.AuthMethodType != proj_model.APIAuthMethodTypeBasic {
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "EVENT-HW4tw", "Errors.Project.APIAuthMethodNoSecret")
|
||||
}
|
||||
repoProject := model.ProjectFromModel(existingProject)
|
||||
|
||||
stringPw, err := app.APIConfig.GenerateNewClientSecret(es.pwGenerator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
projectAggregate := APIConfigSecretChangedAggregate(es.Eventstore.AggregateCreator(), repoProject, appID, app.APIConfig.ClientSecret)
|
||||
err = es_sdk.Push(ctx, es.PushAggregates, repoProject.AppendEvents, projectAggregate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
es.projectCache.cacheProject(repoProject)
|
||||
|
||||
if _, a := model.GetApplication(repoProject.Applications, app.AppID); a != nil {
|
||||
config := model.APIConfigToModel(a.APIConfig)
|
||||
config.ClientSecretString = stringPw
|
||||
return config, nil
|
||||
}
|
||||
|
||||
return nil, caos_errs.ThrowInternal(nil, "EVENT-HBfju", "Errors.Internal")
|
||||
}
|
||||
|
||||
func (es *ProjectEventstore) VerifyOIDCClientSecret(ctx context.Context, projectID, appID string, secret string) (err error) {
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
defer func() { span.EndWithError(err) }()
|
||||
@@ -831,6 +921,81 @@ func (es *ProjectEventstore) setOIDCClientSecretCheckResult(ctx context.Context,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (es *ProjectEventstore) AddClientKey(ctx context.Context, key *proj_model.ClientKey) (*proj_model.ClientKey, error) {
|
||||
existingProject, err := es.ProjectByID(ctx, key.AggregateID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var app *proj_model.Application
|
||||
if _, app = existingProject.GetApp(key.ApplicationID); app == nil {
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "EVENT-Dbf32", "Errors.Project.AppNoExisting")
|
||||
}
|
||||
if (app.OIDCConfig == nil || app.OIDCConfig != nil && app.OIDCConfig.AuthMethodType != proj_model.OIDCAuthMethodTypePrivateKeyJWT) &&
|
||||
(app.APIConfig == nil || app.APIConfig != nil && app.APIConfig.AuthMethodType != proj_model.APIAuthMethodTypePrivateKeyJWT) {
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "EVENT-Dff54", "Errors.Project.AuthMethodNoPrivateKeyJWT")
|
||||
}
|
||||
if app.OIDCConfig != nil {
|
||||
key.ClientID = app.OIDCConfig.ClientID
|
||||
}
|
||||
if app.APIConfig != nil {
|
||||
key.ClientID = app.APIConfig.ClientID
|
||||
}
|
||||
key.KeyID, err = es.idGenerator.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if key.ExpirationDate.IsZero() {
|
||||
key.ExpirationDate, err = key_model.DefaultExpiration()
|
||||
if err != nil {
|
||||
logging.Log("EVENT-Adgf2").WithError(err).Warn("unable to set default date")
|
||||
return nil, errors.ThrowInternal(err, "EVENT-j68fg", "Errors.Internal")
|
||||
}
|
||||
}
|
||||
if key.ExpirationDate.Before(time.Now()) {
|
||||
return nil, errors.ThrowInvalidArgument(nil, "EVENT-C6YV5", "Errors.MachineKey.ExpireBeforeNow")
|
||||
}
|
||||
|
||||
repoProject := model.ProjectFromModel(existingProject)
|
||||
repoKey := model.ClientKeyFromModel(key)
|
||||
err = repoKey.GenerateClientKeyPair(es.ClientKeySize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agg := OIDCApplicationKeyAddedAggregate(es.AggregateCreator(), repoProject, repoKey)
|
||||
err = es_sdk.Push(ctx, es.PushAggregates, repoProject.AppendEvents, agg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
es.projectCache.cacheProject(repoProject)
|
||||
|
||||
return model.ClientKeyToModel(repoKey), nil
|
||||
}
|
||||
|
||||
func (es *ProjectEventstore) RemoveApplicationKey(ctx context.Context, projectID, applicationID, keyID string) error {
|
||||
existingProject, err := es.ProjectByID(ctx, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var app *proj_model.Application
|
||||
if _, app = existingProject.GetApp(applicationID); app == nil {
|
||||
return caos_errs.ThrowPreconditionFailed(nil, "EVENT-ADfzz", "Errors.Project.AppNotExisting")
|
||||
}
|
||||
if app.Type != proj_model.AppTypeOIDC {
|
||||
return caos_errs.ThrowPreconditionFailed(nil, "EVENT-ADffh", "Errors.Project.AppIsNotOIDC")
|
||||
}
|
||||
if _, key := app.GetKey(keyID); key == nil {
|
||||
return caos_errs.ThrowPreconditionFailed(nil, "EVENT-D2Sff", "Errors.Project.AppKeyNotExisting")
|
||||
}
|
||||
repoProject := model.ProjectFromModel(existingProject)
|
||||
agg := OIDCApplicationKeyRemovedAggregate(es.AggregateCreator(), repoProject, keyID)
|
||||
err = es_sdk.Push(ctx, es.PushAggregates, repoProject.AppendEvents, agg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
es.projectCache.cacheProject(repoProject)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (es *ProjectEventstore) ProjectGrantByIDs(ctx context.Context, projectID, grantID string) (*proj_model.ProjectGrant, error) {
|
||||
if grantID == "" {
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "EVENT-e8die", "Errors.Project.IDMissing")
|
||||
|
@@ -0,0 +1,87 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/caos/logging"
|
||||
|
||||
"github.com/caos/zitadel/internal/crypto"
|
||||
es_models "github.com/caos/zitadel/internal/eventstore/models"
|
||||
"github.com/caos/zitadel/internal/project/model"
|
||||
)
|
||||
|
||||
type APIConfig struct {
|
||||
es_models.ObjectRoot
|
||||
AppID string `json:"appId"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
ClientSecret *crypto.CryptoValue `json:"clientSecret,omitempty"`
|
||||
AuthMethodType int32 `json:"authMethodType,omitempty"`
|
||||
ClientKeys []*ClientKey `json:"-"`
|
||||
}
|
||||
|
||||
func (c *APIConfig) Changes(changed *APIConfig) map[string]interface{} {
|
||||
changes := make(map[string]interface{}, 1)
|
||||
changes["appId"] = c.AppID
|
||||
if c.AuthMethodType != changed.AuthMethodType {
|
||||
changes["authMethodType"] = changed.AuthMethodType
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func APIConfigFromModel(config *model.APIConfig) *APIConfig {
|
||||
return &APIConfig{
|
||||
ObjectRoot: config.ObjectRoot,
|
||||
AppID: config.AppID,
|
||||
ClientID: config.ClientID,
|
||||
ClientSecret: config.ClientSecret,
|
||||
AuthMethodType: int32(config.AuthMethodType),
|
||||
}
|
||||
}
|
||||
|
||||
func APIConfigToModel(config *APIConfig) *model.APIConfig {
|
||||
oidcConfig := &model.APIConfig{
|
||||
ObjectRoot: config.ObjectRoot,
|
||||
AppID: config.AppID,
|
||||
ClientID: config.ClientID,
|
||||
ClientSecret: config.ClientSecret,
|
||||
AuthMethodType: model.APIAuthMethodType(config.AuthMethodType),
|
||||
ClientKeys: ClientKeysToModel(config.ClientKeys),
|
||||
}
|
||||
return oidcConfig
|
||||
}
|
||||
|
||||
func (p *Project) appendAddAPIConfigEvent(event *es_models.Event) error {
|
||||
config := new(APIConfig)
|
||||
err := config.setData(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.ObjectRoot.CreationDate = event.CreationDate
|
||||
if i, a := GetApplication(p.Applications, config.AppID); a != nil {
|
||||
p.Applications[i].Type = int32(model.AppTypeAPI)
|
||||
p.Applications[i].APIConfig = config
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Project) appendChangeAPIConfigEvent(event *es_models.Event) error {
|
||||
config := new(APIConfig)
|
||||
err := config.setData(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if i, a := GetApplication(p.Applications, config.AppID); a != nil {
|
||||
return p.Applications[i].APIConfig.setData(event)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *APIConfig) setData(event *es_models.Event) error {
|
||||
o.ObjectRoot.AppendEvent(event)
|
||||
if err := json.Unmarshal(event.Data, o); err != nil {
|
||||
logging.Log("EVEN-d8e3s").WithError(err).Error("could not unmarshal event data")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
@@ -2,7 +2,9 @@ package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/caos/logging"
|
||||
|
||||
es_models "github.com/caos/zitadel/internal/eventstore/models"
|
||||
"github.com/caos/zitadel/internal/project/model"
|
||||
)
|
||||
@@ -14,6 +16,7 @@ type Application struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Type int32 `json:"appType,omitempty"`
|
||||
OIDCConfig *OIDCConfig `json:"-"`
|
||||
APIConfig *APIConfig `json:"-"`
|
||||
}
|
||||
|
||||
type ApplicationID struct {
|
||||
@@ -66,6 +69,9 @@ func AppFromModel(app *model.Application) *Application {
|
||||
if app.OIDCConfig != nil {
|
||||
converted.OIDCConfig = OIDCConfigFromModel(app.OIDCConfig)
|
||||
}
|
||||
if app.APIConfig != nil {
|
||||
converted.APIConfig = APIConfigFromModel(app.APIConfig)
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
@@ -80,6 +86,9 @@ func AppToModel(app *Application) *model.Application {
|
||||
if app.OIDCConfig != nil {
|
||||
converted.OIDCConfig = OIDCConfigToModel(app.OIDCConfig)
|
||||
}
|
||||
if app.APIConfig != nil {
|
||||
converted.APIConfig = APIConfigToModel(app.APIConfig)
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
@@ -101,7 +110,7 @@ func (p *Project) appendChangeAppEvent(event *es_models.Event) error {
|
||||
return err
|
||||
}
|
||||
if i, a := GetApplication(p.Applications, app.AppID); a != nil {
|
||||
p.Applications[i].setData(event)
|
||||
return p.Applications[i].setData(event)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@@ -8,7 +8,9 @@ import (
|
||||
"github.com/caos/logging"
|
||||
|
||||
"github.com/caos/zitadel/internal/crypto"
|
||||
"github.com/caos/zitadel/internal/errors"
|
||||
es_models "github.com/caos/zitadel/internal/eventstore/models"
|
||||
key_model "github.com/caos/zitadel/internal/key/model"
|
||||
"github.com/caos/zitadel/internal/project/model"
|
||||
)
|
||||
|
||||
@@ -30,6 +32,7 @@ type OIDCConfig struct {
|
||||
IDTokenRoleAssertion bool `json:"idTokenRoleAssertion,omitempty"`
|
||||
IDTokenUserinfoAssertion bool `json:"idTokenUserinfoAssertion,omitempty"`
|
||||
ClockSkew time.Duration `json:"clockSkew,omitempty"`
|
||||
ClientKeys []*ClientKey `json:"-"`
|
||||
}
|
||||
|
||||
func (c *OIDCConfig) Changes(changed *OIDCConfig) map[string]interface{} {
|
||||
@@ -134,6 +137,7 @@ func OIDCConfigToModel(config *OIDCConfig) *model.OIDCConfig {
|
||||
IDTokenRoleAssertion: config.IDTokenRoleAssertion,
|
||||
IDTokenUserinfoAssertion: config.IDTokenUserinfoAssertion,
|
||||
ClockSkew: config.ClockSkew,
|
||||
ClientKeys: ClientKeysToModel(config.ClientKeys),
|
||||
}
|
||||
oidcConfig.FillCompliance()
|
||||
return oidcConfig
|
||||
@@ -161,7 +165,50 @@ func (p *Project) appendChangeOIDCConfigEvent(event *es_models.Event) error {
|
||||
}
|
||||
|
||||
if i, a := GetApplication(p.Applications, config.AppID); a != nil {
|
||||
p.Applications[i].OIDCConfig.setData(event)
|
||||
return p.Applications[i].OIDCConfig.setData(event)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Project) appendAddClientKeyEvent(event *es_models.Event) error {
|
||||
key := new(ClientKey)
|
||||
err := key.SetData(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if i, a := GetApplication(p.Applications, key.ApplicationID); a != nil {
|
||||
if a.OIDCConfig != nil {
|
||||
p.Applications[i].OIDCConfig.ClientKeys = append(p.Applications[i].OIDCConfig.ClientKeys, key)
|
||||
}
|
||||
if a.APIConfig != nil {
|
||||
p.Applications[i].APIConfig.ClientKeys = append(p.Applications[i].APIConfig.ClientKeys, key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Project) appendRemoveClientKeyEvent(event *es_models.Event) error {
|
||||
key := new(ClientKey)
|
||||
err := key.SetData(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if i, a := GetApplication(p.Applications, key.ApplicationID); a != nil {
|
||||
if a.OIDCConfig != nil {
|
||||
if j, k := GetClientKey(p.Applications[i].OIDCConfig.ClientKeys, key.KeyID); k != nil {
|
||||
p.Applications[i].OIDCConfig.ClientKeys[j] = p.Applications[i].OIDCConfig.ClientKeys[len(p.Applications[i].OIDCConfig.ClientKeys)-1]
|
||||
p.Applications[i].OIDCConfig.ClientKeys[len(p.Applications[i].OIDCConfig.ClientKeys)-1] = nil
|
||||
p.Applications[i].OIDCConfig.ClientKeys = p.Applications[i].OIDCConfig.ClientKeys[:len(p.Applications[i].OIDCConfig.ClientKeys)-1]
|
||||
}
|
||||
}
|
||||
if a.APIConfig != nil {
|
||||
if j, k := GetClientKey(p.Applications[i].APIConfig.ClientKeys, key.KeyID); k != nil {
|
||||
p.Applications[i].APIConfig.ClientKeys[j] = p.Applications[i].APIConfig.ClientKeys[len(p.Applications[i].APIConfig.ClientKeys)-1]
|
||||
p.Applications[i].APIConfig.ClientKeys[len(p.Applications[i].APIConfig.ClientKeys)-1] = nil
|
||||
p.Applications[i].APIConfig.ClientKeys = p.Applications[i].APIConfig.ClientKeys[:len(p.Applications[i].APIConfig.ClientKeys)-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -174,3 +221,100 @@ func (o *OIDCConfig) setData(event *es_models.Event) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetClientKey(keys []*ClientKey, id string) (int, *ClientKey) {
|
||||
for i, k := range keys {
|
||||
if k.KeyID == id {
|
||||
return i, k
|
||||
}
|
||||
}
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
type ClientKey struct {
|
||||
es_models.ObjectRoot `json:"-"`
|
||||
ApplicationID string `json:"applicationId,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
KeyID string `json:"keyId,omitempty"`
|
||||
Type int32 `json:"type,omitempty"`
|
||||
ExpirationDate time.Time `json:"expirationDate,omitempty"`
|
||||
PublicKey []byte `json:"publicKey,omitempty"`
|
||||
privateKey []byte
|
||||
}
|
||||
|
||||
func (key *ClientKey) SetData(event *es_models.Event) error {
|
||||
key.ObjectRoot.AppendEvent(event)
|
||||
if err := json.Unmarshal(event.Data, key); err != nil {
|
||||
logging.Log("EVEN-SADdg").WithError(err).Error("could not unmarshal event data")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (key *ClientKey) AppendEvents(events ...*es_models.Event) error {
|
||||
for _, event := range events {
|
||||
err := key.AppendEvent(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (key *ClientKey) AppendEvent(event *es_models.Event) (err error) {
|
||||
key.ObjectRoot.AppendEvent(event)
|
||||
switch event.Type {
|
||||
case ClientKeyAdded:
|
||||
err = json.Unmarshal(event.Data, key)
|
||||
if err != nil {
|
||||
return errors.ThrowInternal(err, "MODEL-Fetg3", "Errors.Internal")
|
||||
}
|
||||
case ClientKeyRemoved:
|
||||
key.ExpirationDate = event.CreationDate
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func ClientKeyFromModel(key *model.ClientKey) *ClientKey {
|
||||
return &ClientKey{
|
||||
ObjectRoot: key.ObjectRoot,
|
||||
ExpirationDate: key.ExpirationDate,
|
||||
ApplicationID: key.ApplicationID,
|
||||
ClientID: key.ClientID,
|
||||
KeyID: key.KeyID,
|
||||
Type: int32(key.Type),
|
||||
}
|
||||
}
|
||||
|
||||
func ClientKeysToModel(keys []*ClientKey) []*model.ClientKey {
|
||||
clientKeys := make([]*model.ClientKey, len(keys))
|
||||
for i, key := range keys {
|
||||
clientKeys[i] = ClientKeyToModel(key)
|
||||
}
|
||||
return clientKeys
|
||||
}
|
||||
|
||||
func ClientKeyToModel(key *ClientKey) *model.ClientKey {
|
||||
return &model.ClientKey{
|
||||
ObjectRoot: key.ObjectRoot,
|
||||
ExpirationDate: key.ExpirationDate,
|
||||
ApplicationID: key.ApplicationID,
|
||||
ClientID: key.ClientID,
|
||||
KeyID: key.KeyID,
|
||||
PrivateKey: key.privateKey,
|
||||
Type: key_model.AuthNKeyType(key.Type),
|
||||
}
|
||||
}
|
||||
|
||||
func (key *ClientKey) GenerateClientKeyPair(keySize int) error {
|
||||
privateKey, publicKey, err := crypto.GenerateKeyPair(keySize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key.PublicKey, err = crypto.PublicKeyToBytes(publicKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key.privateKey = crypto.PrivateKeyToBytes(privateKey)
|
||||
return nil
|
||||
}
|
||||
|
@@ -139,6 +139,14 @@ func (p *Project) AppendEvent(event *es_models.Event) error {
|
||||
return p.appendAddOIDCConfigEvent(event)
|
||||
case OIDCConfigChanged, OIDCConfigSecretChanged:
|
||||
return p.appendChangeOIDCConfigEvent(event)
|
||||
case APIConfigAdded:
|
||||
return p.appendAddAPIConfigEvent(event)
|
||||
case APIConfigChanged, APIConfigSecretChanged:
|
||||
return p.appendChangeAPIConfigEvent(event)
|
||||
case ClientKeyAdded:
|
||||
return p.appendAddClientKeyEvent(event)
|
||||
case ClientKeyRemoved:
|
||||
return p.appendRemoveClientKeyEvent(event)
|
||||
case ProjectGrantAdded:
|
||||
return p.appendAddGrantEvent(event)
|
||||
case ProjectGrantChanged, ProjectGrantCascadeChanged:
|
||||
|
@@ -41,4 +41,11 @@ const (
|
||||
OIDCConfigSecretChanged models.EventType = "project.application.config.oidc.secret.changed"
|
||||
OIDCClientSecretCheckSucceeded models.EventType = "project.application.oidc.secret.check.succeeded"
|
||||
OIDCClientSecretCheckFailed models.EventType = "project.application.oidc.secret.check.failed"
|
||||
|
||||
APIConfigAdded models.EventType = "project.application.config.api.added"
|
||||
APIConfigChanged models.EventType = "project.application.config.api.changed"
|
||||
APIConfigSecretChanged models.EventType = "project.application.config.api.secret.changed"
|
||||
|
||||
ClientKeyAdded models.EventType = "project.application.oidc.key.added"
|
||||
ClientKeyRemoved models.EventType = "project.application.oidc.key.removed"
|
||||
)
|
||||
|
@@ -36,6 +36,14 @@ func ProjectAggregate(ctx context.Context, aggCreator *es_models.AggregateCreato
|
||||
return aggCreator.NewAggregate(ctx, project.AggregateID, model.ProjectAggregate, model.ProjectVersion, project.Sequence)
|
||||
}
|
||||
|
||||
func ProjectAggregateOverwriteContext(ctx context.Context, aggCreator *es_models.AggregateCreator, project *model.Project, resourceOwnerID string, userID string) (*es_models.Aggregate, error) {
|
||||
if project == nil {
|
||||
return nil, errors.ThrowPreconditionFailed(nil, "EVENT-ADv2r", "Errors.Internal")
|
||||
}
|
||||
|
||||
return aggCreator.NewAggregate(ctx, project.AggregateID, model.ProjectAggregate, model.ProjectVersion, project.Sequence, es_models.OverwriteResourceOwner(resourceOwnerID), es_models.OverwriteEditorUser(userID))
|
||||
}
|
||||
|
||||
func ProjectCreateAggregate(aggCreator *es_models.AggregateCreator, project *model.Project, member *model.ProjectMember) func(ctx context.Context) (*es_models.Aggregate, error) {
|
||||
return func(ctx context.Context) (*es_models.Aggregate, error) {
|
||||
if project == nil || member == nil {
|
||||
@@ -229,6 +237,9 @@ func ApplicationAddedAggregate(aggCreator *es_models.AggregateCreator, existingP
|
||||
if app.OIDCConfig != nil {
|
||||
agg.AppendEvent(model.OIDCConfigAdded, app.OIDCConfig)
|
||||
}
|
||||
if app.APIConfig != nil {
|
||||
agg.AppendEvent(model.APIConfigAdded, app.APIConfig)
|
||||
}
|
||||
return agg, nil
|
||||
}
|
||||
}
|
||||
@@ -322,6 +333,29 @@ func OIDCConfigChangedAggregate(aggCreator *es_models.AggregateCreator, existing
|
||||
}
|
||||
}
|
||||
|
||||
func APIConfigChangedAggregate(aggCreator *es_models.AggregateCreator, existingProject *model.Project, config *model.APIConfig) func(ctx context.Context) (*es_models.Aggregate, error) {
|
||||
return func(ctx context.Context) (*es_models.Aggregate, error) {
|
||||
if config == nil {
|
||||
return nil, errors.ThrowPreconditionFailed(nil, "EVENT-slf32", "Errors.Internal")
|
||||
}
|
||||
agg, err := ProjectAggregate(ctx, aggCreator, existingProject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var changes map[string]interface{}
|
||||
for _, a := range existingProject.Applications {
|
||||
if a.AppID == config.AppID {
|
||||
if a.APIConfig != nil {
|
||||
changes = a.APIConfig.Changes(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
agg.AppendEvent(model.APIConfigChanged, changes)
|
||||
|
||||
return agg, nil
|
||||
}
|
||||
}
|
||||
|
||||
func OIDCConfigSecretChangedAggregate(aggCreator *es_models.AggregateCreator, existingProject *model.Project, appID string, secret *crypto.CryptoValue) func(ctx context.Context) (*es_models.Aggregate, error) {
|
||||
return func(ctx context.Context) (*es_models.Aggregate, error) {
|
||||
agg, err := ProjectAggregate(ctx, aggCreator, existingProject)
|
||||
@@ -338,6 +372,22 @@ func OIDCConfigSecretChangedAggregate(aggCreator *es_models.AggregateCreator, ex
|
||||
}
|
||||
}
|
||||
|
||||
func APIConfigSecretChangedAggregate(aggCreator *es_models.AggregateCreator, existingProject *model.Project, appID string, secret *crypto.CryptoValue) func(ctx context.Context) (*es_models.Aggregate, error) {
|
||||
return func(ctx context.Context) (*es_models.Aggregate, error) {
|
||||
agg, err := ProjectAggregate(ctx, aggCreator, existingProject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changes := make(map[string]interface{}, 2)
|
||||
changes["appId"] = appID
|
||||
changes["clientSecret"] = secret
|
||||
|
||||
agg.AppendEvent(model.APIConfigSecretChanged, changes)
|
||||
|
||||
return agg, nil
|
||||
}
|
||||
}
|
||||
|
||||
func OIDCClientSecretCheckSucceededAggregate(aggCreator *es_models.AggregateCreator, existingProject *model.Project, appID string) es_sdk.AggregateFunc {
|
||||
return func(ctx context.Context) (*es_models.Aggregate, error) {
|
||||
agg, err := ProjectAggregate(ctx, aggCreator, existingProject)
|
||||
@@ -368,6 +418,33 @@ func OIDCClientSecretCheckFailedAggregate(aggCreator *es_models.AggregateCreator
|
||||
}
|
||||
}
|
||||
|
||||
func OIDCApplicationKeyAddedAggregate(aggCreator *es_models.AggregateCreator, existingProject *model.Project, key *model.ClientKey) es_sdk.AggregateFunc {
|
||||
return func(ctx context.Context) (*es_models.Aggregate, error) {
|
||||
agg, err := ProjectAggregate(ctx, aggCreator, existingProject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agg.AppendEvent(model.ClientKeyAdded, key)
|
||||
|
||||
return agg, nil
|
||||
}
|
||||
}
|
||||
|
||||
func OIDCApplicationKeyRemovedAggregate(aggCreator *es_models.AggregateCreator, existingProject *model.Project, keyID string) es_sdk.AggregateFunc {
|
||||
return func(ctx context.Context) (*es_models.Aggregate, error) {
|
||||
agg, err := ProjectAggregate(ctx, aggCreator, existingProject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changes := make(map[string]interface{}, 1)
|
||||
changes["keyId"] = keyID
|
||||
|
||||
agg.AppendEvent(model.ClientKeyRemoved, changes)
|
||||
|
||||
return agg, nil
|
||||
}
|
||||
}
|
||||
|
||||
func ProjectGrantAddedAggregate(aggCreator *es_models.AggregateCreator, project *model.Project, grant *model.ProjectGrant) func(ctx context.Context) (*es_models.Aggregate, error) {
|
||||
return func(ctx context.Context) (*es_models.Aggregate, error) {
|
||||
if grant == nil {
|
||||
|
@@ -116,17 +116,25 @@ func (a *ApplicationView) AppendEventIfMyApp(event *models.Event) (err error) {
|
||||
switch event.Type {
|
||||
case es_model.ApplicationAdded:
|
||||
err = view.SetData(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case es_model.ApplicationChanged,
|
||||
es_model.OIDCConfigAdded,
|
||||
es_model.OIDCConfigChanged,
|
||||
es_model.APIConfigAdded,
|
||||
es_model.APIConfigChanged,
|
||||
es_model.ApplicationDeactivated,
|
||||
es_model.ApplicationReactivated:
|
||||
err := view.SetData(event)
|
||||
err = view.SetData(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case es_model.ApplicationRemoved:
|
||||
return view.SetData(event)
|
||||
err = view.SetData(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case es_model.ProjectChanged:
|
||||
return a.AppendEvent(event)
|
||||
case es_model.ProjectRemoved:
|
||||
@@ -156,14 +164,20 @@ func (a *ApplicationView) AppendEvent(event *models.Event) (err error) {
|
||||
}
|
||||
a.setCompliance()
|
||||
return a.setOriginAllowList()
|
||||
case es_model.OIDCConfigChanged,
|
||||
es_model.ApplicationChanged:
|
||||
case es_model.APIConfigAdded:
|
||||
a.IsOIDC = false
|
||||
return a.SetData(event)
|
||||
case es_model.ApplicationChanged:
|
||||
return a.SetData(event)
|
||||
case es_model.OIDCConfigChanged:
|
||||
err = a.SetData(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.setCompliance()
|
||||
return a.setOriginAllowList()
|
||||
case es_model.APIConfigChanged:
|
||||
return a.SetData(event)
|
||||
case es_model.ProjectChanged:
|
||||
return a.SetData(event)
|
||||
case es_model.ApplicationDeactivated:
|
||||
|
Reference in New Issue
Block a user