// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 package identity import ( "context" "encoding/json" "io" "net/http" "slices" "strings" "time" "github.com/ory/kratos/x/nosurfx" "github.com/ory/kratos/x/redir" "github.com/ory/x/httprouterx" "github.com/ory/x/httpx" "github.com/gofrs/uuid" "github.com/ory/x/crdbx" "github.com/ory/x/pagination/keysetpagination" "github.com/ory/x/pagination/migrationpagination" "github.com/ory/x/pagination/pagepagination" "github.com/ory/x/sqlcon" "github.com/ory/kratos/hash" "github.com/ory/kratos/selfservice/strategy/deviceauthn" "github.com/ory/kratos/x" "github.com/ory/kratos/cipher" "github.com/ory/herodot" "github.com/pkg/errors" "github.com/ory/x/decoderx" "github.com/ory/x/jsonx" "github.com/ory/x/openapix" "github.com/ory/x/region" "github.com/ory/x/sqlxx" "github.com/ory/x/urlx" "github.com/ory/kratos/driver/config" ) const ( RouteCollection = "/identities" RouteItem = RouteCollection + "/{id}" RouteCredentialItem = RouteItem + "/credentials/{type}" BatchPatchIdentitiesLimit = 1000 BatchPatchIdentitiesWithPasswordLimit = 200 ) type ( dependencies interface { PoolProvider PrivilegedPoolProvider ManagementProvider httpx.WriterProvider config.Provider nosurfx.CSRFProvider cipher.Provider hash.HashProvider } HandlerProvider interface { IdentityHandler() *Handler } Handler struct{ r dependencies } ) func NewHandler(r dependencies) *Handler { return &Handler{r: r} } func (h *Handler) RegisterPublicRoutes(public *httprouterx.RouterPublic) { h.r.CSRFHandler().IgnoreGlobs( RouteCollection, RouteCollection+"/*", RouteCollection+"/*/credentials/*", httprouterx.AdminPrefix+RouteCollection, httprouterx.AdminPrefix+RouteCollection+"/*", httprouterx.AdminPrefix+RouteCollection+"/*/credentials/*", ) public.GET(RouteCollection, redir.RedirectToAdminRoute(h.r)) public.GET(RouteCollection+"/by/external/{externalID}", redir.RedirectToAdminRoute(h.r)) public.GET(RouteItem, redir.RedirectToAdminRoute(h.r)) public.DELETE(RouteItem, redir.RedirectToAdminRoute(h.r)) public.POST(RouteCollection, redir.RedirectToAdminRoute(h.r)) public.PUT(RouteItem, redir.RedirectToAdminRoute(h.r)) public.PATCH(RouteItem, redir.RedirectToAdminRoute(h.r)) public.DELETE(RouteCredentialItem, redir.RedirectToAdminRoute(h.r)) public.GET(httprouterx.AdminPrefix+RouteCollection, redir.RedirectToAdminRoute(h.r)) public.GET(httprouterx.AdminPrefix+RouteCollection+"/by/external/{externalID}", redir.RedirectToAdminRoute(h.r)) public.GET(httprouterx.AdminPrefix+RouteItem, redir.RedirectToAdminRoute(h.r)) public.DELETE(httprouterx.AdminPrefix+RouteItem, redir.RedirectToAdminRoute(h.r)) public.POST(httprouterx.AdminPrefix+RouteCollection, redir.RedirectToAdminRoute(h.r)) public.PUT(httprouterx.AdminPrefix+RouteItem, redir.RedirectToAdminRoute(h.r)) public.PATCH(httprouterx.AdminPrefix+RouteItem, redir.RedirectToAdminRoute(h.r)) public.DELETE(httprouterx.AdminPrefix+RouteCredentialItem, redir.RedirectToAdminRoute(h.r)) } func (h *Handler) RegisterAdminRoutes(admin *httprouterx.RouterAdmin) { admin.GET(RouteCollection, h.list) admin.GET(RouteItem, h.get) admin.GET(RouteCollection+"/by/external/{externalID}", h.getByExternalID) admin.DELETE(RouteItem, h.delete) admin.PATCH(RouteItem, h.patch) admin.POST(RouteCollection, h.create) admin.PATCH(RouteCollection, h.batchPatchIdentities) admin.PUT(RouteItem, h.update) admin.DELETE(RouteCredentialItem, h.deleteIdentityCredentials) } // Paginated Identity List Response // // swagger:response listIdentities type _ struct { migrationpagination.ResponseHeaderAnnotation // List of identities // // in:body Body []Identity } // Paginated List Identity Parameters // // Note: Filters cannot be combined. // // swagger:parameters listIdentities type _ struct { migrationpagination.RequestParameters // Retrieve multiple identities by their IDs. // // This parameter has the following limitations: // // - Duplicate or non-existent IDs are ignored. // - The order of returned IDs may be different from the request. // - This filter does not support pagination. You must implement your own pagination as the maximum number of items returned by this endpoint may not exceed a certain threshold (currently 500). // // required: false // in: query IdsFilter []string `json:"ids"` // CredentialsIdentifier is the identifier (username, email) of the credentials to look up using exact match. // Only one of CredentialsIdentifier and CredentialsIdentifierSimilar can be used. // // required: false // in: query CredentialsIdentifier string `json:"credentials_identifier"` // This is an EXPERIMENTAL parameter that WILL CHANGE. Do NOT rely on consistent, deterministic behavior. // THIS PARAMETER WILL BE REMOVED IN AN UPCOMING RELEASE WITHOUT ANY MIGRATION PATH. // // CredentialsIdentifierSimilar is the (partial) identifier (username, email) of the credentials to look up using similarity search. // Only one of CredentialsIdentifier and CredentialsIdentifierSimilar can be used. // // required: false // in: query CredentialsIdentifierSimilar string `json:"preview_credentials_identifier_similar"` // Include Credentials in Response // // Include any credential, for example `password` or `oidc`, in the response. When set to `oidc`, This will return // the initial OAuth 2.0 Access Token, OAuth 2.0 Refresh Token, and the OpenID Connect ID Token if available. // // required: false // in: query DeclassifyCredentials []string `json:"include_credential"` // List identities that belong to a specific organization. // // required: false // in: query OrganizationID string `json:"organization_id"` crdbx.ConsistencyRequestParameters } func parseListIdentitiesParameters(r *http.Request) (params ListIdentityParameters, err error) { query := r.URL.Query() var requestedFilters int params.Expand = ExpandDefault if ids := query["ids"]; len(ids) > 0 { requestedFilters++ for _, v := range ids { id, err := uuid.FromString(v) if err != nil { return params, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Invalid UUID value `%s` for parameter `ids`.", v)) } params.IdsFilter = append(params.IdsFilter, id) } } if len(params.IdsFilter) > 500 { return params, errors.WithStack(herodot.ErrBadRequest().WithReason("The number of ids to filter must not exceed 500.")) } if orgID := query.Get("organization_id"); orgID != "" { requestedFilters++ params.OrganizationID, err = uuid.FromString(orgID) if err != nil { return params, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Invalid UUID value `%s` for parameter `organization_id`.", orgID)) } } if identifier := query.Get("credentials_identifier"); identifier != "" { requestedFilters++ params.Expand = ExpandEverything params.CredentialsIdentifier = identifier } if identifier := query.Get("preview_credentials_identifier_similar"); identifier != "" { requestedFilters++ params.Expand = ExpandEverything params.CredentialsIdentifierSimilar = identifier } for _, v := range query["include_credential"] { params.Expand = ExpandEverything tc, ok := ParseCredentialsType(v) if !ok { return params, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Invalid value `%s` for parameter `include_credential`.", v)) } params.DeclassifyCredentials = append(params.DeclassifyCredentials, tc) } if requestedFilters > 1 { return params, errors.WithStack(herodot.ErrBadRequest().WithReason("You cannot combine multiple filters in this API")) } params.KeySetPagination, params.PagePagination, err = x.ParseKeysetOrPagePagination(r) if err != nil { return params, err } params.ConsistencyLevel = crdbx.ConsistencyLevelFromRequest(r) return params, nil } // swagger:route GET /admin/identities identity listIdentities // // # List Identities // // Lists all [identities](https://www.ory.com/docs/kratos/concepts/identity-user-model) in the system. Note: filters cannot be combined. // // Produces: // - application/json // // Schemes: http, https // // Security: // oryAccessToken: // // Responses: // 200: listIdentities // default: errorGeneric // // Extensions: // x-ory-ratelimit-bucket: kratos-admin-medium func (h *Handler) list(w http.ResponseWriter, r *http.Request) { params, err := parseListIdentitiesParameters(r) if err != nil { h.r.Writer().WriteError(w, r, err) return } is, nextPage, err := h.r.IdentityPool().ListIdentities(r.Context(), params) if err != nil { h.r.Writer().WriteError(w, r, err) return } if params.PagePagination != nil { total := int64(len(is)) if params.CredentialsIdentifier == "" { total, err = h.r.IdentityPool().CountIdentities(r.Context()) if err != nil { h.r.Writer().WriteError(w, r, err) return } } u := *r.URL pagepagination.PaginationHeader(w, &u, total, params.PagePagination.Page, params.PagePagination.ItemsPerPage) } else if nextPage != nil { u := *r.URL keysetpagination.Header(w, &u, nextPage) } // Identities using the marshaler for including metadata_admin isam := make([]WithCredentialsAndAdminMetadataInJSON, len(is)) for i, identity := range is { emit, err := identity.WithDeclassifiedCredentials(r.Context(), h.r, params.DeclassifyCredentials) if err != nil { h.r.Writer().WriteError(w, r, err) return } isam[i] = WithCredentialsAndAdminMetadataInJSON(*emit) } h.r.Writer().Write(w, r, isam) } // Get Identity Parameters // // swagger:parameters getIdentity // //nolint:deadcode,unused //lint:ignore U1000 Used to generate Swagger and OpenAPI definitions type getIdentity struct { // ID must be set to the ID of identity you want to get // // required: true // in: path ID string `json:"id"` // Include Credentials in Response // // Include any credential, for example `password` or `oidc`, in the response. When set to `oidc`, This will return // the initial OAuth 2.0 Access Token, OAuth 2.0 Refresh Token, and the OpenID Connect ID Token if available. // // required: false // in: query DeclassifyCredentials []CredentialsType `json:"include_credential"` } // Get Identity By External ID Parameters // // swagger:parameters getIdentityByExternalID // //nolint:deadcode,unused //lint:ignore U1000 Used to generate Swagger and OpenAPI definitions type getIdentityByExternalID struct { // ExternalID must be set to the ID of identity you want to get // // required: true // in: path ExternalID string `json:"externalID"` // Include Credentials in Response // // Include any credential, for example `password` or `oidc`, in the response. When set to `oidc`, This will return // the initial OAuth 2.0 Access Token, OAuth 2.0 Refresh Token, and the OpenID Connect ID Token if available. // // required: false // in: query DeclassifyCredentials []CredentialsType `json:"include_credential"` } // swagger:route GET /admin/identities/{id} identity getIdentity // // # Get an Identity // // Return an [identity](https://www.ory.com/docs/kratos/concepts/identity-user-model) by its ID. You can optionally // include credentials (e.g. social sign in connections) in the response by using the `include_credential` query parameter. // // Consumes: // - application/json // // Produces: // - application/json // // Schemes: http, https // // Security: // oryAccessToken: // // Responses: // 200: identity // 404: errorGeneric // default: errorGeneric // // Extensions: // x-ory-ratelimit-bucket: kratos-admin-high func (h *Handler) get(w http.ResponseWriter, r *http.Request) { i, err := h.r.PrivilegedIdentityPool().GetIdentityConfidential(r.Context(), x.ParseUUID(r.PathValue("id"))) if err != nil { h.r.Writer().WriteError(w, r, err) return } includeCredentials := r.URL.Query()["include_credential"] var declassify []CredentialsType for _, v := range includeCredentials { tc, ok := ParseCredentialsType(v) if ok { declassify = append(declassify, tc) } else { h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Invalid value `%s` for parameter `include_credential`.", declassify))) return } } emit, err := i.WithDeclassifiedCredentials(r.Context(), h.r, declassify) if err != nil { h.r.Writer().WriteError(w, r, err) return } h.r.Writer().Write(w, r, WithCredentialsAndAdminMetadataInJSON(*emit)) } // swagger:route GET /admin/identities/by/external/{externalID} identity getIdentityByExternalID // // # Get an Identity by its External ID // // Return an [identity](https://www.ory.com/docs/kratos/concepts/identity-user-model) by its external ID. You can optionally // include credentials (e.g. social sign in connections) in the response by using the `include_credential` query parameter. // // Consumes: // - application/json // // Produces: // - application/json // // Schemes: http, https // // Security: // oryAccessToken: // // Responses: // 200: identity // 404: errorGeneric // default: errorGeneric // // Extensions: // x-ory-ratelimit-bucket: kratos-admin-medium func (h *Handler) getByExternalID(w http.ResponseWriter, r *http.Request) { externalID := r.PathValue("externalID") if externalID == "" { h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReason("The external ID must not be empty."))) return } i, err := h.r.PrivilegedIdentityPool().FindIdentityByExternalID(r.Context(), externalID, ExpandEverything) if err != nil { h.r.Writer().WriteError(w, r, err) return } includeCredentials := r.URL.Query()["include_credential"] var declassify []CredentialsType for _, v := range includeCredentials { tc, ok := ParseCredentialsType(v) if ok { declassify = append(declassify, tc) } else { h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Invalid value `%s` for parameter `include_credential`.", declassify))) return } } emit, err := i.WithDeclassifiedCredentials(r.Context(), h.r, declassify) if err != nil { h.r.Writer().WriteError(w, r, err) return } h.r.Writer().Write(w, r, WithCredentialsAndAdminMetadataInJSON(*emit)) } // Create Identity Parameters // // swagger:parameters createIdentity // //nolint:deadcode,unused //lint:ignore U1000 Used to generate Swagger and OpenAPI definitions type createIdentity struct { // in: body Body CreateIdentityBody } // Create Identity Body // // swagger:model createIdentityBody type CreateIdentityBody struct { // SchemaID is the ID of the JSON Schema to be used for validating the identity's traits. // // required: true SchemaID string `json:"schema_id"` // Traits represent an identity's traits. The identity is able to create, modify, and delete traits // in a self-service manner. The input will always be validated against the JSON Schema defined // in `schema_url`. // // required: true Traits json.RawMessage `json:"traits"` // Credentials represents all credentials that can be used for authenticating this identity. // // Use this structure to import credentials for a user. Credentials *IdentityWithCredentials `json:"credentials"` // VerifiableAddresses contains all the addresses that can be verified by the user. // // Use this structure to import verified addresses for an identity. Please keep in mind // that the address needs to be represented in the Identity Schema or this field will be overwritten // on the next identity update. VerifiableAddresses []VerifiableAddress `json:"verifiable_addresses"` // RecoveryAddresses contains all the addresses that can be used to recover an identity. // // Use this structure to import recovery addresses for an identity. Please keep in mind // that the address needs to be represented in the Identity Schema or this field will be overwritten // on the next identity update. RecoveryAddresses []RecoveryAddress `json:"recovery_addresses"` // Store metadata about the identity which the identity itself can see when calling for example the // session endpoint. Do not store sensitive information (e.g. credit score) about the identity in this field. MetadataPublic json.RawMessage `json:"metadata_public"` // Store metadata about the user which is only accessible through admin APIs such as `GET /admin/identities/`. MetadataAdmin json.RawMessage `json:"metadata_admin,omitempty"` // State is the identity's state. // // required: false State State `json:"state"` // OrganizationID is the ID of the organization to which the identity belongs. // // required: false OrganizationID uuid.NullUUID `json:"organization_id"` // ExternalID is an optional external ID of the identity. This is used to link // the identity to an external system. If set, the external ID must be unique // across all identities. // // required: false ExternalID string `json:"external_id,omitempty"` // Region is the Ory Network region this identity will be created in. // Optional; defaults to the project home region if omitted. Only effective // on the Ory Network. // // required: false Region region.Region `json:"region,omitempty"` } // Create Identity and Import Credentials // // swagger:model identityWithCredentials type IdentityWithCredentials struct { // Password if set will import a password credential. Password *AdminIdentityImportCredentialsPassword `json:"password"` // OIDC if set will import an OIDC credential. OIDC *AdminIdentityImportCredentialsOIDC `json:"oidc"` // SAML if set will import a SAML credential. SAML *AdminIdentityImportCredentialsSAML `json:"saml"` // TOTP if set will import a TOTP credential. TOTP *AdminIdentityImportCredentialsTOTP `json:"totp"` // WebAuthn if set will import a WebAuthn credential. WebAuthn *AdminIdentityImportCredentialsWebAuthn `json:"webauthn"` // Passkey if set will import a Passkey credential. Passkey *AdminIdentityImportCredentialsPasskey `json:"passkey"` // LookupSecret if set will import a magic code credential. LookupSecret *AdminIdentityImportCredentialsLookupSecret `json:"lookup_secret"` } // Create Identity and Import TOTP 2FA Credentials // // swagger:model identityWithCredentialsTotp type AdminIdentityImportCredentialsTOTP struct { // Configuration options for the import. Config AdminIdentityImportCredentialsTOTPConfig `json:"config"` } // Create Identity and Import TOTP 2FA Credentials Configuration // // swagger:model identityWithCredentialsTotpConfig type AdminIdentityImportCredentialsTOTPConfig struct { // TOTPURL is the TOTP URL // // For more details see: https://github.com/google/google-authenticator/wiki/Key-Uri-Format TOTPURL string `json:"totp_url"` } // Create Identity and Import WebAuthn Credentials // // swagger:model identityWithCredentialsWebAuthn type AdminIdentityImportCredentialsWebAuthn struct { // Configuration options for the import. Config AdminIdentityImportCredentialsWebAuthnConfig `json:"config"` } // Create Identity and Import WebAuthn Credentials Configuration // // swagger:model identityWithCredentialsWebAuthnConfig type AdminIdentityImportCredentialsWebAuthnConfig struct { // List of webauthn credentials. Credentials CredentialsWebAuthn `json:"credentials"` // UserHandle is the user handle of the webauthn credential. UserHandle []byte `json:"user_handle"` } // Create Identity and Import Passkey Credentials // // swagger:model identityWithCredentialsPasskey type AdminIdentityImportCredentialsPasskey struct { // Configuration options for the import. Config AdminIdentityImportCredentialsPasskeyConfig `json:"config"` } // Create Identity and Import Passkey Credentials Configuration // // swagger:model identityWithCredentialsPasskeyConfig type AdminIdentityImportCredentialsPasskeyConfig struct { // List of webauthn credentials. Credentials CredentialsWebAuthn `json:"credentials"` // UserHandle is the user handle of the webauthn credential. UserHandle []byte `json:"user_handle"` } // Create Identity and Import Lookup Secret Credentials // // swagger:model identityWithCredentialsLookup Secret type AdminIdentityImportCredentialsLookupSecret struct { // Configuration options for the import. Config AdminIdentityImportCredentialsLookupSecretConfig `json:"config"` } // Create Identity and Import Lookup Secret Credentials Configuration // // swagger:model identityWithCredentialsLookup SecretConfig type AdminIdentityImportCredentialsLookupSecretConfig struct { // Codes is a list of "lookup secret" codes configured for the user. Codes []RecoveryCode `json:"codes"` } // Create Identity and Import Password Credentials // // swagger:model identityWithCredentialsPassword type AdminIdentityImportCredentialsPassword struct { // Configuration options for the import. Config AdminIdentityImportCredentialsPasswordConfig `json:"config"` } // Create Identity and Import Password Credentials Configuration // // swagger:model identityWithCredentialsPasswordConfig type AdminIdentityImportCredentialsPasswordConfig struct { // The hashed password in [PHC format](https://www.ory.com/docs/kratos/manage-identities/import-user-accounts-identities#hashed-passwords) HashedPassword string `json:"hashed_password"` // The password in plain text if no hash is available. Password string `json:"password"` // If set to true, the password will be migrated using the password migration hook. UsePasswordMigrationHook bool `json:"use_password_migration_hook,omitempty"` } // Create Identity and Import Social Sign In Credentials // // swagger:model identityWithCredentialsOidc type AdminIdentityImportCredentialsOIDC struct { // Configuration options for the import. Config AdminIdentityImportCredentialsOIDCConfig `json:"config"` } // swagger:model identityWithCredentialsOidcConfig type AdminIdentityImportCredentialsOIDCConfig struct { // A list of OpenID Connect Providers Providers []AdminCreateIdentityImportCredentialsOIDCProvider `json:"providers"` } // Create Identity and Import Social Sign In Credentials Configuration // // swagger:model identityWithCredentialsOidcConfigProvider type AdminCreateIdentityImportCredentialsOIDCProvider struct { // The subject (`sub`) of the OpenID Connect connection. Usually the `sub` field of the ID Token. // // required: true Subject string `json:"subject"` // The OpenID Connect provider to link the subject to. Usually something like `google` or `github`. // // required: true Provider string `json:"provider"` // If set, this credential allows the user to sign in using the OpenID Connect provider without setting the subject first. // // required: false UseAutoLink bool `json:"use_auto_link,omitempty"` // The organization to assign for the provider. Organization uuid.NullUUID `json:"organization,omitempty"` } // Payload to import SAML credentials // // swagger:model identityWithCredentialsSaml type AdminIdentityImportCredentialsSAML struct { // Configuration options for the import. Config AdminIdentityImportCredentialsSAMLConfig `json:"config"` } // Payload of SAML providers // // swagger:model identityWithCredentialsSamlConfig type AdminIdentityImportCredentialsSAMLConfig struct { // A list of SAML Providers Providers []AdminCreateIdentityImportCredentialsSAMLProvider `json:"providers"` } // Payload of specific SAML provider // // swagger:model identityWithCredentialsSamlConfigProvider type AdminCreateIdentityImportCredentialsSAMLProvider struct { // The unique subject of the SAML connection. This value must be immutable at the source. // // required: true Subject string `json:"subject"` // The SAML provider to link the subject to. // // required: true Provider string `json:"provider"` // The organization to assign for the provider. Organization uuid.NullUUID `json:"organization"` } // swagger:route POST /admin/identities identity createIdentity // // # Create an Identity // // Create an [identity](https://www.ory.com/docs/kratos/concepts/identity-user-model). This endpoint can also be used to // [import credentials](https://www.ory.com/docs/kratos/manage-identities/import-user-accounts-identities) // for instance passwords, social sign in configurations, or multifactor methods. // // Consumes: // - application/json // // Produces: // - application/json // // Schemes: http, https // // Security: // oryAccessToken: // // Responses: // 201: identity // 400: errorGeneric // 409: errorGeneric // default: errorGeneric // // Extensions: // x-ory-ratelimit-bucket: kratos-admin-low func (h *Handler) create(w http.ResponseWriter, r *http.Request) { var cr CreateIdentityBody if err := jsonx.NewStrictDecoder(r.Body).Decode(&cr); err != nil { h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithError(err.Error()))) return } i, err := h.identityFromCreateIdentityBody(r.Context(), &cr) if err != nil { h.r.Writer().WriteError(w, r, err) return } if err := h.r.IdentityManager().Create(r.Context(), i); err != nil { if errors.Is(err, sqlcon.ErrUniqueViolation()) { h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrConflict().WithReason("This identity conflicts with another identity that already exists."))) } else { h.r.Writer().WriteError(w, r, err) } return } h.r.Writer().WriteCreated(w, r, urlx.AppendPaths( h.r.Config().SelfAdminURL(r.Context()), "identities", i.ID.String(), ).String(), WithCredentialsNoConfigAndAdminMetadataInJSON(*i), ) } func (h *Handler) identityFromCreateIdentityBody(ctx context.Context, cr *CreateIdentityBody) (*Identity, error) { stateChangedAt := sqlxx.NullTime(time.Now().UTC()) state := StateActive if cr.State != "" { if err := cr.State.IsValid(); err != nil { return nil, errors.WithStack(herodot.ErrBadRequest().WithReasonf("%s", err).WithWrap(err)) } state = cr.State } // Validate at the API boundary; the persister re-checks under home_region. if cr.Region != "" && !cr.Region.Valid() { return nil, region.NewErrInvalid() } i := &Identity{ SchemaID: cr.SchemaID, Traits: []byte(cr.Traits), State: state, StateChangedAt: &stateChangedAt, VerifiableAddresses: cr.VerifiableAddresses, RecoveryAddresses: cr.RecoveryAddresses, MetadataAdmin: []byte(cr.MetadataAdmin), MetadataPublic: []byte(cr.MetadataPublic), OrganizationID: cr.OrganizationID, ExternalID: sqlxx.NullString(cr.ExternalID), Region: cr.Region, } // Lowercase all emails, because the schema extension will otherwise not find them. for k := range i.VerifiableAddresses { i.VerifiableAddresses[k].Value = strings.ToLower(i.VerifiableAddresses[k].Value) } for k := range i.RecoveryAddresses { i.RecoveryAddresses[k].Value = strings.ToLower(i.RecoveryAddresses[k].Value) } if err := h.importCredentials(ctx, i, cr.Credentials); err != nil { return nil, err } return i, nil } // swagger:route PATCH /admin/identities identity batchPatchIdentities // // # Create multiple identities // // Creates multiple [identities](https://www.ory.com/docs/kratos/concepts/identity-user-model). // // You can also use this endpoint to [import credentials](https://www.ory.com/docs/kratos/manage-identities/import-user-accounts-identities), // including passwords, social sign-in settings, and multi-factor authentication methods. // // If the patch includes hashed passwords you can import up to 1,000 identities per request. // // If the patch includes at least one plaintext password you can import up to 200 identities per request. // // Avoid importing large batches with plaintext passwords. They can cause timeouts as the passwords need to be hashed before they are stored. // // If at least one identity is imported successfully, the response status is 200 OK. // If all imports fail, the response is one of the following 4xx errors: // - 400 Bad Request: The request payload is invalid or improperly formatted. // - 409 Conflict: Duplicate identities or conflicting data were detected. // // If you get a 504 Gateway Timeout: // - Reduce the batch size // - Avoid duplicate identities // - Pre-hash passwords with BCrypt // // If the issue persists, contact support. // // Consumes: // - application/json // // Produces: // - application/json // // Schemes: http, https // // Security: // oryAccessToken: // // Responses: // 200: batchPatchIdentitiesResponse // 400: errorGeneric // 409: errorGeneric // default: errorGeneric // // Extensions: // x-ory-ratelimit-bucket: kratos-admin-low func (h *Handler) batchPatchIdentities(w http.ResponseWriter, r *http.Request) { var ( req BatchPatchIdentitiesBody res batchPatchIdentitiesResponse ) if err := jsonx.NewStrictDecoder(r.Body).Decode(&req); err != nil { h.r.Writer().WriteErrorCode(w, r, http.StatusBadRequest, errors.WithStack(err)) return } if len(req.Identities) > BatchPatchIdentitiesLimit { h.r.Writer().WriteErrorCode(w, r, http.StatusBadRequest, errors.WithStack(herodot.ErrBadRequest().WithReasonf( "The maximum number of identities per request that can be created or deleted at once is %d.", BatchPatchIdentitiesLimit))) return } res.Identities = make([]*BatchIdentityPatchResponse, len(req.Identities)) // Array to look up the index of the identity in the identities array. indexInIdentities := make([]*int, len(req.Identities)) identities := make([]*Identity, 0, len(req.Identities)) var withUnHashedPasswordCount int for i, patch := range req.Identities { if patch.Create != nil { res.Identities[i] = &BatchIdentityPatchResponse{ Action: ActionCreate, PatchID: patch.ID, } identity, err := h.identityFromCreateIdentityBody(r.Context(), patch.Create) if err != nil { h.r.Writer().WriteError(w, r, err) return } identities = append(identities, identity) idx := len(identities) - 1 indexInIdentities[i] = &idx if patch.Create.Credentials != nil && patch.Create.Credentials.Password != nil && patch.Create.Credentials.Password.Config.Password != "" { withUnHashedPasswordCount++ } } } if withUnHashedPasswordCount > BatchPatchIdentitiesWithPasswordLimit { h.r.Writer().WriteErrorCode(w, r, http.StatusBadRequest, errors.WithStack(herodot.ErrBadRequest().WithReasonf( "The maximum number of identities per request that can be created with a plaintext password is %d.", BatchPatchIdentitiesWithPasswordLimit))) return } err := h.r.IdentityManager().CreateIdentities(r.Context(), identities) partialErr := new(CreateIdentitiesError) if err != nil && !errors.As(err, &partialErr) { h.r.Writer().WriteError(w, r, err) return } for resIdx, identitiesIdx := range indexInIdentities { if identitiesIdx != nil { ident := identities[*identitiesIdx] // Check if the identity was created successfully. if failed := partialErr.Find(ident); failed != nil { res.Identities[resIdx].Action = ActionError res.Identities[resIdx].Error = failed.Error } else { res.Identities[resIdx].IdentityID = &ident.ID } } } h.r.Writer().Write(w, r, &res) } // Update Identity Parameters // // swagger:parameters updateIdentity // //nolint:deadcode,unused //lint:ignore U1000 Used to generate Swagger and OpenAPI definitions type updateIdentity struct { // ID must be set to the ID of identity you want to update // // required: true // in: path ID string `json:"id"` // in: body Body UpdateIdentityBody } // Update Identity Body // // swagger:model updateIdentityBody type UpdateIdentityBody struct { // SchemaID is the ID of the JSON Schema to be used for validating the identity's traits. If set // will update the Identity's SchemaID. // // required: true SchemaID string `json:"schema_id"` // Traits represent an identity's traits. The identity is able to create, modify, and delete traits // in a self-service manner. The input will always be validated against the JSON Schema defined // in `schema_id`. // // required: true Traits json.RawMessage `json:"traits"` // Credentials represents all credentials that can be used for authenticating this identity. // // Use this structure to import credentials for a user. // Note: this wil override completely identity's credentials. If used incorrectly, this can cause a user to lose // access to their account! Credentials *IdentityWithCredentials `json:"credentials"` // Store metadata about the identity which the identity itself can see when calling for example the // session endpoint. Do not store sensitive information (e.g. credit score) about the identity in this field. MetadataPublic json.RawMessage `json:"metadata_public"` // Store metadata about the user which is only accessible through admin APIs such as `GET /admin/identities/`. MetadataAdmin json.RawMessage `json:"metadata_admin,omitempty"` // State is the identity's state. // // required: true State State `json:"state"` // ExternalID is an optional external ID of the identity. This is used to link // the identity to an external system. If set, the external ID must be unique // across all identities. // // required: false ExternalID string `json:"external_id,omitempty"` // Region is the Ory Network region this identity is homed in. Optional; // omit to leave the current region unchanged. // // required: false Region region.Region `json:"region,omitempty"` } // swagger:route PUT /admin/identities/{id} identity updateIdentity // // # Update an Identity // // This endpoint updates an [identity](https://www.ory.com/docs/kratos/concepts/identity-user-model). The full identity // payload (except credentials) is expected. // // It is possible to update the identity's credentials as well. Using this operation, credentials will not be overwritten // but instead added to the list. For example, if a user has a social sign in connection set up, updating the credentials // will keep the social sign in connection and add the new credentials to the list. This prevents accidentally overwriting // credentials and locking out users. A complete view of all credential types is here: // // - `password`: The existing password credential will be completely replaced with the new configuration. You can provide either a hashed password, a plaintext password (which will be hashed), or enable the password migration hook. // - `oidc`, `saml`: The existing OIDC and SAML credentials will be kept and the new credentials will be added to the list. // - `totp`: The existing TOTP credentials will be replaced with the new configuration. // - `lookup_secret`: The existing Lookup Secret codes will be kept and the new codes will be added to the list. // - `webauthn`, `passkey`: The existing credentials are preserved, new credentials are added, and credentials with matching IDs are updated with new values. If a new `user_handle` is provided, it's added to the identity's identifiers list while preserving previous user handles. // - `code`: To import code credentials, configure your identity schema to use one of the identity traits as an identifier source (`{"ory.sh/kratos":{"code":{"identifier":true", "via":"email"}}}`). // // Consumes: // - application/json // // Produces: // - application/json // // Schemes: http, https // // Security: // oryAccessToken: // // Responses: // 200: identity // 400: errorGeneric // 404: errorGeneric // 409: errorGeneric // default: errorGeneric // // Extensions: // x-ory-ratelimit-bucket: kratos-admin-low func (h *Handler) update(w http.ResponseWriter, r *http.Request) { var ur UpdateIdentityBody if err := decoderx.Decode(r, &ur, decoderx.HTTPJSONDecoder()); err != nil { h.r.Writer().WriteError(w, r, err) return } id := x.ParseUUID(r.PathValue("id")) identity, err := h.r.PrivilegedIdentityPool().GetIdentityConfidential(r.Context(), id) if err != nil { h.r.Writer().WriteError(w, r, err) return } if ur.SchemaID != "" { identity.SchemaID = ur.SchemaID } if ur.State != "" && identity.State != ur.State { if err := ur.State.IsValid(); err != nil { h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("%s", err).WithWrap(err))) return } stateChangedAt := sqlxx.NullTime(time.Now()) identity.State = ur.State identity.StateChangedAt = &stateChangedAt } identity.Traits = []byte(ur.Traits) identity.MetadataPublic = []byte(ur.MetadataPublic) identity.MetadataAdmin = []byte(ur.MetadataAdmin) identity.ExternalID = sqlxx.NullString(ur.ExternalID) // Empty preserves the current region; older clients that pre-date the // field must not silently wipe it. if ur.Region != "" { if !ur.Region.Valid() { h.r.Writer().WriteError(w, r, region.NewErrInvalid()) return } identity.Region = ur.Region } // Although this is PUT and not PATCH, if the Credentials are not supplied keep the old one if ur.Credentials != nil { if err := h.importCredentials(r.Context(), identity, ur.Credentials); err != nil { h.r.Writer().WriteError(w, r, err) return } } if err := h.r.IdentityManager().Update( r.Context(), identity, ManagerAllowWriteProtectedTraits, ); err != nil { h.r.Writer().WriteError(w, r, err) return } h.r.Writer().Write(w, r, WithCredentialsNoConfigAndAdminMetadataInJSON(*identity)) } // Delete Identity Parameters // // swagger:parameters deleteIdentity // //nolint:deadcode,unused //lint:ignore U1000 Used to generate Swagger and OpenAPI definitions type deleteIdentity struct { // ID is the identity's ID. // // required: true // in: path ID string `json:"id"` } // swagger:route DELETE /admin/identities/{id} identity deleteIdentity // // # Delete an Identity // // Calling this endpoint irrecoverably and permanently deletes the [identity](https://www.ory.com/docs/kratos/concepts/identity-user-model) given its ID. This action can not be undone. // This endpoint returns 204 when the identity was deleted or 404 if the identity was not found. // // Produces: // - application/json // // Schemes: http, https // // Security: // oryAccessToken: // // Responses: // 204: emptyResponse // 404: errorGeneric // default: errorGeneric // // Extensions: // x-ory-ratelimit-bucket: kratos-admin-low func (h *Handler) delete(w http.ResponseWriter, r *http.Request) { if err := h.r.PrivilegedIdentityPool().DeleteIdentity(r.Context(), x.ParseUUID(r.PathValue("id"))); err != nil { h.r.Writer().WriteError(w, r, err) return } w.WriteHeader(http.StatusNoContent) } // Patch Identity Parameters // // swagger:parameters patchIdentity // //nolint:deadcode,unused //lint:ignore U1000 Used to generate Swagger and OpenAPI definitions type patchIdentity struct { // ID must be set to the ID of identity you want to update // // required: true // in: path ID string `json:"id"` // in: body Body openapix.JSONPatchDocument } // patchOp is a minimal JSON Patch op used for /region pre-processing. type patchOp struct { Op string `json:"op"` Path string `json:"path"` From string `json:"from,omitempty"` Value json.RawMessage `json:"value,omitempty"` } // applyRegionPatchOps applies /region ops to ident.Region and returns the // patch with those ops removed. Only add/replace are accepted — remove and // other ops on /region return 400 (callers must always set a region // explicitly). func applyRegionPatchOps(body []byte, ident *Identity) ([]byte, error) { var ops []patchOp if err := json.Unmarshal(body, &ops); err != nil { return nil, errors.WithStack(herodot.ErrBadRequest(). WithReason("failed to unmarshal JSON patch"). WithError(err.Error()). WithWrap(err)) } rest := make([]patchOp, 0, len(ops)) for _, op := range ops { if op.Path != "/region" { rest = append(rest, op) continue } if op.Op != "add" && op.Op != "replace" { return nil, errors.WithStack(herodot.ErrBadRequest(). WithReasonf("operation %q is not supported on /region", op.Op)) } var v string if err := json.Unmarshal(op.Value, &v); err != nil { return nil, errors.WithStack(herodot.ErrBadRequest(). WithReason("failed to unmarshal").WithWrap(err)) } r := region.Region(v) if !r.Valid() { return nil, region.NewErrInvalid() } ident.Region = r } if len(rest) == 0 { return []byte("[]"), nil } return json.Marshal(rest) } // swagger:route PATCH /admin/identities/{id} identity patchIdentity // // # Patch an Identity // // Partially updates an [identity's](https://www.ory.com/docs/kratos/concepts/identity-user-model) field using [JSON Patch](https://jsonpatch.com/). // The fields `id`, `stateChangedAt` and `credentials` can not be updated using this method. // // Consumes: // - application/json // // Produces: // - application/json // // Schemes: http, https // // Security: // oryAccessToken: // // Responses: // 200: identity // 400: errorGeneric // 404: errorGeneric // 409: errorGeneric // default: errorGeneric // // Extensions: // x-ory-ratelimit-bucket: kratos-admin-low func (h *Handler) patch(w http.ResponseWriter, r *http.Request) { requestBody, err := io.ReadAll(r.Body) if err != nil { h.r.Writer().WriteError(w, r, err) return } id := x.ParseUUID(r.PathValue("id")) ident, err := h.r.PrivilegedIdentityPool().GetIdentityConfidential(r.Context(), id) if err != nil { h.r.Writer().WriteError(w, r, err) return } // Apply /region ops to ident.Region and strip them from the patch. // ApplyJSONPatch can't handle them directly because the field is // omitempty and a "remove" on an absent key would error. filteredPatch, err := applyRegionPatchOps(requestBody, ident) if err != nil { h.r.Writer().WriteError(w, r, err) return } oldState := ident.State patchedIdentity, err := jsonx.ApplyJSONPatch(filteredPatch, WithCredentialsAndAdminMetadataInJSON(*ident), "/id", "/stateChangedAt", "/credentials", "/credentials/oidc", "/credentials/oidc/**", "/credentials/saml", "/credentials/saml/**") if err != nil { h.r.Writer().WriteError(w, r, errors.WithStack( herodot.ErrBadRequest(). WithReason("failed to apply JSON patch"). WithError(err.Error()). WithWrap(err), )) return } // Reject patches that desynchronize a credential's "type" field from // its map key. The patch deny-list lets callers replace whole // `/credentials/` subtrees or add new keys; a value without a // "type" field would otherwise either fail deep inside the persister // with a misleading "code bug" 500 (new key path) or silently // overwrite the existing credential's config (replace path) — for // password credentials this destroys `hashed_password`. // IdentityManager.ValidateIdentity also enforces this as a safeguard, // but the handler-level check rejects the request before the // confidential-identity reload and gives a sharper error path. if err := ValidateCredentialsIntegrity(patchedIdentity.Credentials); err != nil { h.r.Writer().WriteError(w, r, err) return } // ApplyJSONPatch can't see omitempty-stripped fields; carry forward. patchedIdentity.Region = ident.Region if oldState != patchedIdentity.State { // Check if the changed state was actually valid if err := patchedIdentity.State.IsValid(); err != nil { h.r.Writer().WriteError(w, r, errors.WithStack( herodot.ErrBadRequest(). WithReasonf("The supplied state ('%s') was not valid. Valid states are ('%s', '%s').", string(patchedIdentity.State), StateActive, StateInactive). WithErrorf("%v", err). WithWrap(err), )) return } // If the state changed, we need to update the timestamp of it stateChangedAt := sqlxx.NullTime(time.Now()) patchedIdentity.StateChangedAt = &stateChangedAt } updatedIdentity := Identity(patchedIdentity) if err := h.r.IdentityManager().Update( r.Context(), &updatedIdentity, ManagerAllowWriteProtectedTraits, ); err != nil { h.r.Writer().WriteError(w, r, err) return } h.r.Writer().Write(w, r, WithCredentialsNoConfigAndAdminMetadataInJSON(updatedIdentity)) } // Delete Credential Parameters // // swagger:parameters deleteIdentityCredentials type _ struct { // ID is the identity's ID. // // required: true // in: path ID string `json:"id"` // Type is the type of credentials to delete. // // required: true // in: path Type CredentialsType `json:"type"` // Identifier is the identifier of the credential to delete. It is required // for the `oidc`, `saml`, and `deviceauthn` credential types: for `oidc` // and `saml` it selects the provider link to remove, for `deviceauthn` it // is the `client_key_id` of the device key to revoke. Find the identifier // by calling the `GET /admin/identities/{id}?include_credential={type}` // endpoint. // // required: false // in: query Identifier string `json:"identifier"` } // swagger:route DELETE /admin/identities/{id}/credentials/{type} identity deleteIdentityCredentials // // # Delete a credential for a specific identity // // Delete an [identity](https://www.ory.com/docs/kratos/concepts/identity-user-model) credential by its type. // You cannot delete passkeys or code auth credentials through this API. // // Consumes: // - application/json // // Produces: // - application/json // // Schemes: http, https // // Security: // oryAccessToken: // // Responses: // 204: emptyResponse // 400: errorGeneric // 404: errorGeneric // default: errorGeneric // // Extensions: // x-ory-ratelimit-bucket: kratos-admin-low func (h *Handler) deleteIdentityCredentials(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // DeviceAuthn revokes a single key selected by the `identifier` query // parameter (the key's client_key_id) rather than dropping the whole // credential. Its read-modify-write runs under an exclusive row lock (see // deleteDeviceAuthnKey), so it does not go through the shared in-memory // read-then-update path below. if CredentialsType(r.PathValue("type")) == CredentialsTypeDeviceAuthn { if err := h.deleteDeviceAuthnKey(ctx, x.ParseUUID(r.PathValue("id")), r.URL.Query().Get("identifier")); err != nil { h.r.Writer().WriteError(w, r, err) return } w.WriteHeader(http.StatusNoContent) return } identity, err := h.r.PrivilegedIdentityPool().GetIdentityConfidential(ctx, x.ParseUUID(r.PathValue("id"))) if err != nil { h.r.Writer().WriteError(w, r, err) return } cred, ok := identity.GetCredentials(CredentialsType(r.PathValue("type"))) if !ok { h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrNotFound().WithReasonf("You tried to remove a %s but this user have no %s set up.", r.PathValue("type"), r.PathValue("type")))) return } switch cred.Type { case CredentialsTypeLookup, CredentialsTypeTOTP: identity.DeleteCredentialsType(cred.Type) case CredentialsTypeWebAuthn: if err = identity.deleteCredentialWebAuthFromIdentity(); err != nil { h.r.Writer().WriteError(w, r, err) return } case CredentialsTypePassword, CredentialsTypeOIDC, CredentialsTypeSAML: firstFactor, err := h.r.IdentityManager().CountActiveFirstFactorCredentials(ctx, identity) if err != nil { h.r.Writer().WriteError(w, r, err) return } if firstFactor < 2 { h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReason("You cannot remove the last first factor credential."))) return } switch cred.Type { case CredentialsTypePassword: if err := identity.deleteCredentialPassword(); err != nil { h.r.Writer().WriteError(w, r, err) return } case CredentialsTypeOIDC, CredentialsTypeSAML: if err := identity.deleteCredentialOIDCSAMLFromIdentity(cred.Type, r.URL.Query().Get("identifier")); err != nil { h.r.Writer().WriteError(w, r, err) return } } default: // A bunch of credential type deletions are not yet implemented, e.g. passkeys, etc. h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Credentials type %s cannot be deleted.", cred.Type))) return } if err := h.r.IdentityManager().Update( ctx, identity, ManagerAllowWriteProtectedTraits, ); err != nil { h.r.Writer().WriteError(w, r, err) return } w.WriteHeader(http.StatusNoContent) } // deleteDeviceAuthnKey revokes the DeviceAuthn key whose client_key_id equals // identifier. The read-modify-write of the credential config runs under the // persister's exclusive row lock (SELECT ... FOR UPDATE), so concurrent // revocations serialize and none is lost; the credential's identifier rows are // synced to the surviving keys inside the same transaction. When the last key // is removed the config is left empty (the credential row is not deleted), // mirroring the DeviceAuthn settings flow. It returns 400 when identifier is // empty and 404 when the identity has no DeviceAuthn credential or no key // matches. func (h *Handler) deleteDeviceAuthnKey(ctx context.Context, id uuid.UUID, identifier string) error { if identifier == "" { return errors.WithStack(herodot.ErrBadRequest().WithReason("You must provide an identifier to delete this credential.")) } return h.r.PrivilegedIdentityPool().UpdateCredentialsConfig( ctx, id, CredentialsTypeDeviceAuthn, UpdateConfig(func(cfg *deviceauthn.CredentialsDeviceAuthnConfig) error { before := len(cfg.Credentials) cfg.Credentials = slices.DeleteFunc(cfg.Credentials, func(k deviceauthn.Key) bool { return k.ClientKeyID == identifier }) if len(cfg.Credentials) == before { return errors.WithStack(herodot.ErrNotFound().WithReasonf("The identifier `%s` was not found among deviceauthn credentials.", identifier)) } return nil }), // The identifier rows are derived state of the config: sync them to the // surviving keys' client_key_ids under the same lock. WithDerivedIdentifiers(func(newConfig []byte) ([]string, error) { var cfg deviceauthn.CredentialsDeviceAuthnConfig if err := json.Unmarshal(newConfig, &cfg); err != nil { return nil, errors.WithStack(herodot.ErrInternalServerError().WithReason("Unable to decode identity credentials.").WithDebug(err.Error())) } ids := make([]string, len(cfg.Credentials)) for i, k := range cfg.Credentials { ids[i] = k.ClientKeyID } return ids, nil }), ) }