Get user's persona
Resolves the persona linked to the user through common/links, then reads it. The link must be stored with the persona on the x side and the user on the y side, which is the orientation POST /v1/persona-links writes. A user with no such link answers 404. A LINK THAT POINTS AT A ROW THAT IS GONE ANSWERS 500, NOT 404: the read is a bare First() whose error is wrapped as common.database_error without separating gorm.ErrRecordNotFound. Because DELETE /v1/personas/{persona_id} is a hard delete that leaves the links behind, this is a state a client will actually meet. The permission checked is read on the USER record, not on the persona — so a caller who may read the user gets the persona whether or not they hold a grant on the persona itself.
Authorization
bearerAuth In: header
Path Parameters
User id. The persona is found through the user↔persona link, not by a column on the persona itself.
Response Body
application/json
application/json
application/json
application/json
application/json
application/json
application/json
application/json
curl -X GET "https://example.com/v1/personas/user/497f6eca-6276-4993-bfeb-53cbbbba6f08"{
"first_name": "string",
"last_name": "string",
"date_of_birth": "2019-08-24",
"date_of_death": "2019-08-24",
"nationality": "string",
"hash_id": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"active": true,
"metadata": {},
"created_at": "2019-08-24T14:15:22Z",
"created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
"modified_at": "2019-08-24T14:15:22Z",
"modified_by": "e8d4374d-93a1-4e98-a6c6-fdcf00c5059f"
}{
"status": 400,
"message": "Invalid input",
"code": "common.invalid_input",
"class": "validation"
}{
"status": 401,
"message": "Unauthorized",
"code": "common.unauthorized",
"class": "business"
}{
"status": 403,
"message": "No access to the record",
"code": "common.rbac_no_rec_access",
"class": "business"
}{
"status": 404,
"message": "Record not found",
"code": "common.record_not_found",
"class": "business"
}{
"status": 429,
"message": "Rate limit exceeded",
"code": "rate_limits_m.exceeded",
"class": "temporary",
"retryable": true
}{
"status": 0,
"message": "string",
"code": "string",
"class": "validation",
"retryable": true,
"details": [
{
"field": "string",
"rule": "string",
"param": "string",
"message": "string"
}
]
}{
"overall_status": "unhealthy",
"message": "Service is shutting down",
"timestamp": "2026-08-27T15:04:05Z"
}HARD DELETE. models.BaseModel declares no gorm.DeletedAt, so DB.Delete issues a real DELETE and the row is gone — this is not the logical delete used elsewhere in the platform, and there is no way to recover the persona through the API. Answers 200 through Ok200, so the body is {"status": 200, "message": "OK"} — not 204 and not the deleted record. The RBAC grants written at create time are not cleaned up.
Updates the persona linked to a user. Carries every caveat of PUT /v1/personas/{persona_id} — no validation, and Updates(struct) skips zero values so active: false and empty strings do nothing — plus two of its own. THE LINK IS RESOLVED FROM THE CALLER'S OWN RECORD GRANTS, NOT FROM THE PATH USER'S LINKS. The handler calls links.FetchLinksByType with the AUTHENTICATED CALLER's id and canReadAll hardcoded to false, so it sees only links touching a user record the caller — or one of the caller's roles — holds a row for in rbac.record_permissions. rbac.RecPermission, which guards the route, returns true for an Administrator without consulting that table at all, so an Administrator passes the permission check and then finds no links: the caller must hold an explicit record grant on the user in the path for this route to resolve anything. IT ONLY WORKS WHEN EXACTLY ONE LINK TOUCHES THAT USER. The ids collected from those links are bound to "id = ?", which GORM expands as a parenthesised list: one id gives id = ($1) and updates that row; none gives id = (NULL), which matches nothing; two or more gives id = ($1,$2), which PostgreSQL rejects as a comparison against a row constructor. Every failure lands in the same branch and answers 404 having written nothing. The collected ids are not filtered by record type either — the customer-to-user and signatory-to-user links other modules create count toward the same total, and a single one of them can be handed to First() in place of the persona, which then finds no persona row and also answers 404.