Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
1528 lines
49 KiB
Go
1528 lines
49 KiB
Go
// 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/<id>`.
|
|
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/<id>`.
|
|
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/<key>` 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
|
|
}),
|
|
)
|
|
}
|