From 82c76e5cefd0f80a8149a08da759c648ec2d4234 Mon Sep 17 00:00:00 2001 From: Mohammed Alosayli Date: Tue, 5 Nov 2024 15:30:23 +0300 Subject: [PATCH 1/4] feat: add inactive identity deactivation Adds functionality to automatically deactivate identities that have been inactive for a configurable period. Key changes: - Add config option for inactivity threshold period - Add API endpoint to list and deactivate inactive identities - Add SQL queries to find and update inactive identities - Add manager and persister methods to handle deactivation --- .gitignore | 1 + driver/config/config.go | 5 ++ identity/handler.go | 71 +++++++++++++++++++ identity/manager.go | 15 ++++ identity/pool.go | 6 ++ .../sql/identity/persister_identity.go | 49 ++++++++++++- 6 files changed, 146 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d5a894b30bef..263dca0c18c0 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,4 @@ test/e2e/kratos.*.yml __debug_bin* .debug.sqlite.db .last-run.json +.env diff --git a/driver/config/config.go b/driver/config/config.go index 075233ef9ae8..790e32f7fb29 100644 --- a/driver/config/config.go +++ b/driver/config/config.go @@ -169,6 +169,7 @@ const ( ViperKeySelfServiceVerificationNotifyUnknownRecipients = "selfservice.flows.verification.notify_unknown_recipients" ViperKeyDefaultIdentitySchemaID = "identity.default_schema_id" ViperKeyIdentitySchemas = "identity.schemas" + ViperKeyIdentityInactivityThresholdInMonths = "identity.identity_inactivity_threshold_in_months" ViperKeyHasherAlgorithm = "hashers.algorithm" ViperKeyHasherArgon2ConfigMemory = "hashers.argon2.memory" ViperKeyHasherArgon2ConfigIterations = "hashers.argon2.iterations" @@ -606,6 +607,10 @@ func (p *Config) DefaultIdentityTraitsSchemaID(ctx context.Context) string { return p.GetProvider(ctx).String(ViperKeyDefaultIdentitySchemaID) } +func (p *Config) IdentityInactivityThresholdInMonths(ctx context.Context) int { + return p.GetProvider(ctx).IntF(ViperKeyIdentityInactivityThresholdInMonths, 12) +} + func (p *Config) TOTPIssuer(ctx context.Context) string { return p.GetProvider(ctx).StringF(ViperKeyTOTPIssuer, p.SelfPublicURL(ctx).Hostname()) } diff --git a/identity/handler.go b/identity/handler.go index 58578d56e72c..b216e85078ab 100644 --- a/identity/handler.go +++ b/identity/handler.go @@ -8,6 +8,7 @@ import ( "encoding/json" "io" "net/http" + "strconv" "strings" "time" @@ -44,6 +45,8 @@ const ( RouteItem = RouteCollection + "/:id" RouteCredentialItem = RouteItem + "/credentials/:type" + AdminRouteInactiveIdentities = "inactive-identities" + BatchPatchIdentitiesLimit = 2000 ) @@ -114,6 +117,8 @@ func (h *Handler) RegisterAdminRoutes(admin *x.RouterAdmin) { admin.PUT(RouteItem, h.update) admin.DELETE(RouteCredentialItem, h.deleteIdentityCredentials) + + admin.PUT(AdminRouteInactiveIdentities, h.listInactiveIdentities) } // Paginated Identity List Response @@ -1023,3 +1028,69 @@ func (h *Handler) deleteIdentityCredentials(w http.ResponseWriter, r *http.Reque w.WriteHeader(http.StatusNoContent) } + +// swagger:route PUT /admin/inactive-identities identity listInactiveIdentities +// +// # List Inactive Identities +// +// Lists and deactivates identities that have been inactive for a configurable period of time. An identity is considered +// inactive if it has no active sessions within the configured inactivity threshold period. The endpoint +// returns basic identity information including ID, state, and timestamps. +// +// This endpoint will run deactivation logic on all identities who have not been active for X months. +// +// Produces: +// - application/json +// +// Schemes: http, https +// +// Security: +// oryAccessToken: +// +// Query Parameters: +// limit: The maximum number of identities to return. +// +// Responses: +// 200: listInactiveIdentitiesResponse +// 400: errorGeneric +// default: errorGeneric +func (h *Handler) listInactiveIdentities(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + limit, err := strconv.Atoi(r.URL.Query().Get("limit")) + if err != nil { + limit = 50 // Default limit + } + + if limit <= 0 { + h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest.WithReasonf("Limit must be greater than 0."))) + return + } + + inactiveIdentities, err := h.r.IdentityManager().ListIdentitiesForDeactivation( + r.Context(), + h.r.Config().IdentityInactivityThresholdInMonths(r.Context()), + limit, + ) + if err != nil { + h.r.Writer().WriteError(w, r, err) + return + } + + identities := make([]map[string]interface{}, len(inactiveIdentities)) + ids := make([]string, len(inactiveIdentities)) + for i, id := range inactiveIdentities { + identities[i] = map[string]interface{}{ + "id": id.ID, + "state": id.State, + "created_at": id.CreatedAt, + "updated_at": id.UpdatedAt, + } + ids[i] = id.ID.String() + } + + if err := h.r.IdentityManager().DeactivateIdentities(r.Context(), ids); err != nil { + h.r.Writer().WriteError(w, r, err) + return + } + + h.r.Writer().Write(w, r, identities) +} diff --git a/identity/manager.go b/identity/manager.go index 3bc5b08e0158..4b376d8fd467 100644 --- a/identity/manager.go +++ b/identity/manager.go @@ -337,6 +337,7 @@ func (e *CreateIdentitiesError) Error() string { e.init() return fmt.Sprintf("create identities error: %d identities failed", len(e.failedIdentities)) } + func (e *CreateIdentitiesError) Unwrap() []error { e.init() var errs []error @@ -350,17 +351,20 @@ func (e *CreateIdentitiesError) AddFailedIdentity(ident *Identity, err *herodot. e.init() e.failedIdentities[ident] = err } + func (e *CreateIdentitiesError) Merge(other *CreateIdentitiesError) { e.init() for k, v := range other.failedIdentities { e.failedIdentities[k] = v } } + func (e *CreateIdentitiesError) Contains(ident *Identity) bool { e.init() _, found := e.failedIdentities[ident] return found } + func (e *CreateIdentitiesError) Find(ident *Identity) *FailedIdentity { e.init() if err, found := e.failedIdentities[ident]; found { @@ -369,12 +373,14 @@ func (e *CreateIdentitiesError) Find(ident *Identity) *FailedIdentity { return nil } + func (e *CreateIdentitiesError) ErrOrNil() error { if e.failedIdentities == nil || len(e.failedIdentities) == 0 { return nil } return e } + func (e *CreateIdentitiesError) init() { if e.failedIdentities == nil { e.failedIdentities = map[*Identity]*herodot.DefaultError{} @@ -584,3 +590,12 @@ func (m *Manager) CountActiveMultiFactorCredentials(ctx context.Context, i *Iden } return count, nil } + +func (m *Manager) ListIdentitiesForDeactivation(ctx context.Context, period int, limit int) (identities []*Identity, err error) { + m.r.Logger().Println("Listing identities to deactivate") + return m.r.PrivilegedIdentityPool().ListIdentitiesForDeactivation(ctx, period, limit) +} + +func (m *Manager) DeactivateIdentities(ctx context.Context, ids []string) (err error) { + return m.r.PrivilegedIdentityPool().DeactivateIdentities(ctx, ids) +} diff --git a/identity/pool.go b/identity/pool.go index e07a6b8ee83e..3efb0a9d0145 100644 --- a/identity/pool.go +++ b/identity/pool.go @@ -114,6 +114,12 @@ type ( // FindIdentityByWebauthnUserHandle returns an identity matching a webauthn user handle. FindIdentityByWebauthnUserHandle(ctx context.Context, userHandle []byte) (*Identity, error) + + // ListIdentitiesForDeactivation lists identities that have not been active for a given period. + ListIdentitiesForDeactivation(ctx context.Context, period int, limit int) ([]*Identity, error) + + // DeactivateIdentities deactivates identities. + DeactivateIdentities(ctx context.Context, ids []string) error } ) diff --git a/persistence/sql/identity/persister_identity.go b/persistence/sql/identity/persister_identity.go index 3e982730398d..9b77ace32aa9 100644 --- a/persistence/sql/identity/persister_identity.go +++ b/persistence/sql/identity/persister_identity.go @@ -633,7 +633,6 @@ func (p *IdentityPersister) CreateIdentities(ctx context.Context, identities ... for _, k := range paritalErr.Failed { failedIdentityIDs[k.IdentityID] = struct{}{} } - } else if paritalErr := new(batch.PartialConflictError[identity.CredentialIdentifier]); errors.As(err, &paritalErr) { for _, k := range paritalErr.Failed { credID := k.IdentityCredentialsID @@ -1335,3 +1334,51 @@ func (p *IdentityPersister) InjectTraitsSchemaURL(ctx context.Context, i *identi i.SchemaURL = s.SchemaURL(p.r.Config().SelfPublicURL(ctx)).String() return nil } + +func (p *IdentityPersister) ListIdentitiesForDeactivation(ctx context.Context, period int, limit int) (identities []*identity.Identity, err error) { + p.r.Logger().Println("Listing identities to deactivate") + + q := p.GetConnection(ctx).RawQuery(` + WITH last_active_sessions AS ( + SELECT + identity_id, + MAX(expires_at) as last_session_expiry + FROM + sessions + GROUP BY + identity_id + ) + SELECT + i.id, + i.state, + i.created_at, + i.updated_at + FROM + identities i + INNER JOIN last_active_sessions las ON i.id = las.identity_id + WHERE + las.last_session_expiry < NOW() - (? || ' months')::INTERVAL + and i.state = 'active' + LIMIT ? + `, fmt.Sprintf("%d", period), limit) + + if err := q.All(&identities); err != nil { + return nil, err + } + + return identities, nil +} + +func (p *IdentityPersister) DeactivateIdentities(ctx context.Context, ids []string) (err error) { + p.r.Logger().Println("Deactivating identities...") + + q := p.GetConnection(ctx).RawQuery(` + UPDATE identities SET state = ? WHERE id IN (?) + `, identity.StateInactive, ids) + + if err := q.Exec(); err != nil { + return err + } + + return nil +} From babaf58ba8bd68d4ca59b1f0c17f5cba0451ba1f Mon Sep 17 00:00:00 2001 From: Mohammed Alosayli Date: Tue, 5 Nov 2024 15:36:22 +0300 Subject: [PATCH 2/4] refactor: only run deactivation if there are any inactive identities --- identity/handler.go | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/identity/handler.go b/identity/handler.go index b216e85078ab..3f7ea6ec8d21 100644 --- a/identity/handler.go +++ b/identity/handler.go @@ -1076,20 +1076,23 @@ func (h *Handler) listInactiveIdentities(w http.ResponseWriter, r *http.Request, } identities := make([]map[string]interface{}, len(inactiveIdentities)) - ids := make([]string, len(inactiveIdentities)) - for i, id := range inactiveIdentities { - identities[i] = map[string]interface{}{ - "id": id.ID, - "state": id.State, - "created_at": id.CreatedAt, - "updated_at": id.UpdatedAt, + + if len(inactiveIdentities) > 0 { + ids := make([]string, len(inactiveIdentities)) + for i, id := range inactiveIdentities { + identities[i] = map[string]interface{}{ + "id": id.ID, + "state": id.State, + "created_at": id.CreatedAt, + "updated_at": id.UpdatedAt, + } + ids[i] = id.ID.String() } - ids[i] = id.ID.String() - } - if err := h.r.IdentityManager().DeactivateIdentities(r.Context(), ids); err != nil { - h.r.Writer().WriteError(w, r, err) - return + if err := h.r.IdentityManager().DeactivateIdentities(r.Context(), ids); err != nil { + h.r.Writer().WriteError(w, r, err) + return + } } h.r.Writer().Write(w, r, identities) From 0b6906cd102c69f2a41f9f749f72d04d8ae58b2e Mon Sep 17 00:00:00 2001 From: Mohammed Alosayli Date: Tue, 5 Nov 2024 15:55:08 +0300 Subject: [PATCH 3/4] chore: add identity inactivity threshold config key --- driver/config/config.go | 2 +- embedx/config.schema.json | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/driver/config/config.go b/driver/config/config.go index 790e32f7fb29..55176b06df4b 100644 --- a/driver/config/config.go +++ b/driver/config/config.go @@ -169,7 +169,7 @@ const ( ViperKeySelfServiceVerificationNotifyUnknownRecipients = "selfservice.flows.verification.notify_unknown_recipients" ViperKeyDefaultIdentitySchemaID = "identity.default_schema_id" ViperKeyIdentitySchemas = "identity.schemas" - ViperKeyIdentityInactivityThresholdInMonths = "identity.identity_inactivity_threshold_in_months" + ViperKeyIdentityInactivityThresholdInMonths = "identity.inactivity_threshold_in_months" ViperKeyHasherAlgorithm = "hashers.algorithm" ViperKeyHasherArgon2ConfigMemory = "hashers.argon2.memory" ViperKeyHasherArgon2ConfigIterations = "hashers.argon2.iterations" diff --git a/embedx/config.schema.json b/embedx/config.schema.json index 97cc30545895..e8f06daef2f3 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -2647,6 +2647,15 @@ }, "required": ["id", "url"] } + }, + "inactivity_threshold_in_months": { + "title": "Identity Inactivity Threshold", + "description": "The period (in months) after which an identity is considered inactive if there are no active sessions for it.", + "type": "integer", + "minimum": 1, + "maximum": 12, + "default": 6, + "examples": [1, 6, 12] } }, "required": ["schemas"], From 2c790a0296ff7941e22b7ad84114501736b698dc Mon Sep 17 00:00:00 2001 From: Mohammed Alosayli Date: Tue, 5 Nov 2024 16:29:08 +0300 Subject: [PATCH 4/4] chore: new config to schema json file --- .schema/version.schema.json | 17 +++++++++++++++++ .schemastore/config.schema.json | 14 ++++++++++++++ embedx/config.schema.json | 2 ++ 3 files changed, 33 insertions(+) diff --git a/.schema/version.schema.json b/.schema/version.schema.json index 72c2cc617d6b..2064e0208006 100644 --- a/.schema/version.schema.json +++ b/.schema/version.schema.json @@ -2,6 +2,23 @@ "$id": "https://github.com/ory/kratos/.schema/versions.config.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", "oneOf": [ + { + "allOf": [ + { + "properties": { + "version": { + "const": "v1.5.0" + } + }, + "required": [ + "version" + ] + }, + { + "$ref": "https://raw.githubusercontent.com/nayla-finance/kratos/v1.5.0/.schemastore/config.schema.json" + } + ] + }, { "allOf": [ { diff --git a/.schemastore/config.schema.json b/.schemastore/config.schema.json index 7ce3c6f8800d..4332e52a66f9 100644 --- a/.schemastore/config.schema.json +++ b/.schemastore/config.schema.json @@ -1310,12 +1310,14 @@ "properties": { "attempt_window": { "type": "string", + "description": "The time window in which login attempts are considered. After this window, failed attempts are forgotten.", "pattern": "^([0-9]+(s|m|h))+$", "default": "5m", "examples": ["1h", "1m", "30s"] }, "max_attempts": { "type": "number", + "description": "The maximum number of failed login attempts (within the attempt window) before a user is blocked.", "default": "3", "examples": ["3", "5"] } @@ -2060,6 +2062,9 @@ "properties": { "email": { "$ref": "#/definitions/emailCourierTemplate" + }, + "sms": { + "$ref": "#/definitions/smsCourierTemplate" } }, "required": ["email"] @@ -2644,6 +2649,15 @@ }, "required": ["id", "url"] } + }, + "inactivity_threshold_in_months": { + "title": "Identity Inactivity Threshold", + "description": "The period (in months) after which an identity is considered inactive if there are no active sessions for it.", + "type": "integer", + "minimum": 1, + "maximum": 12, + "default": 6, + "examples": [1, 6, 12] } }, "required": ["schemas"], diff --git a/embedx/config.schema.json b/embedx/config.schema.json index e8f06daef2f3..5c8a9151ff7c 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -1310,12 +1310,14 @@ "properties": { "attempt_window": { "type": "string", + "description": "The time window in which login attempts are considered. After this window, failed attempts are forgotten.", "pattern": "^([0-9]+(s|m|h))+$", "default": "5m", "examples": ["1h", "1m", "1s"] }, "max_attempts": { "type": "number", + "description": "The maximum number of failed login attempts (within the attempt window) before a user is blocked.", "default": "3", "examples": ["3", "5"] }