From 669753caf6020c708260645b1af911dd091d3fdd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 10:45:22 +0000 Subject: [PATCH 01/10] BE-491: Make shortnames case-insensitive Shortnames are used in URLs and should be case-insensitive. Previously, `@Timd` and `@timd` could be two different users, and restricted names like `admin` could be bypassed with `Admin`. This fix: - Adds database migration V49 to create a case-insensitive unique index on shortnames using LOWER() - Normalizes shortnames to lowercase when storing in the web table - Normalizes shortnames to lowercase when looking up by shortname - Makes restricted shortname check case-insensitive - Makes shortname uniqueness check case-insensitive - Normalizes shortnames during user and org creation - Handles case-insensitive comparison in user update hooks https://claude.ai/code/session_01JQNdEYEowZ22Vj3bTjSVGD --- .../user-before-update-entity-hook-callback.ts | 14 +++++++++++--- .../graph/knowledge/system-types/account.fields.ts | 9 ++++++--- .../src/graph/knowledge/system-types/org.ts | 5 ++++- .../src/graph/knowledge/system-types/user.ts | 5 ++++- .../graph/authorization/src/policies/store/mod.rs | 4 +++- .../V49__case_insensitive_shortname.sql | 9 +++++++++ .../graph/postgres-store/src/store/postgres/mod.rs | 12 +++++++++--- 7 files changed, 46 insertions(+), 12 deletions(-) create mode 100644 libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql diff --git a/apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts b/apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts index 586cdda0c83..c30292e7382 100644 --- a/apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts +++ b/apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts @@ -163,7 +163,14 @@ export const userBeforeEntityUpdateHookCallback: BeforeUpdateEntityHookCallback ); if (updatedShortname) { - if (currentShortname && currentShortname !== updatedShortname) { + // Normalize shortnames to lowercase for case-insensitive comparison + const normalizedUpdatedShortname = updatedShortname.toLowerCase(); + const normalizedCurrentShortname = currentShortname?.toLowerCase(); + + if ( + normalizedCurrentShortname && + normalizedCurrentShortname !== normalizedUpdatedShortname + ) { throw Error.badUserInput("Cannot change shortname"); } @@ -171,7 +178,7 @@ export const userBeforeEntityUpdateHookCallback: BeforeUpdateEntityHookCallback await validateAccountShortname( context, { actorId: user.accountId }, - updatedShortname, + normalizedUpdatedShortname, ); } } @@ -225,7 +232,8 @@ export const userBeforeEntityUpdateHookCallback: BeforeUpdateEntityHookCallback await updateWebShortname( context.graphApi, { actorId: systemAccountId }, - { webId: user.accountId as WebId, shortname: updatedShortname }, + // Normalize shortname to lowercase for case-insensitive uniqueness + { webId: user.accountId as WebId, shortname: updatedShortname.toLowerCase() }, ); } }; diff --git a/apps/hash-api/src/graph/knowledge/system-types/account.fields.ts b/apps/hash-api/src/graph/knowledge/system-types/account.fields.ts index 51a9d0b0a10..879d41baf07 100644 --- a/apps/hash-api/src/graph/knowledge/system-types/account.fields.ts +++ b/apps/hash-api/src/graph/knowledge/system-types/account.fields.ts @@ -107,7 +107,8 @@ export const shortnameIsRestricted: PureGraphFunction< { shortname: string }, boolean > = ({ shortname }): boolean => { - return RESTRICTED_SHORTNAMES.includes(shortname); + // Case-insensitive check: restricted shortnames are all lowercase + return RESTRICTED_SHORTNAMES.includes(shortname.toLowerCase()); }; // TODO: Depending on the approached chosen outlined in `get*ByShortname` functions, this function may be changed @@ -123,9 +124,11 @@ export const shortnameIsTaken: ImpureGraphFunction< * * @see https://linear.app/hash/issue/H-2989 */ + // Normalize shortname to lowercase for case-insensitive check + const normalizedParams = { shortname: params.shortname.toLowerCase() }; return ( - (await getUser(ctx, authentication, params)) !== null || - (await getOrgByShortname(ctx, authentication, params)) !== null + (await getUser(ctx, authentication, normalizedParams)) !== null || + (await getOrgByShortname(ctx, authentication, normalizedParams)) !== null ); }; diff --git a/apps/hash-api/src/graph/knowledge/system-types/org.ts b/apps/hash-api/src/graph/knowledge/system-types/org.ts index 86ea139b682..44759906452 100644 --- a/apps/hash-api/src/graph/knowledge/system-types/org.ts +++ b/apps/hash-api/src/graph/knowledge/system-types/org.ts @@ -119,13 +119,16 @@ export const createOrg: ImpureGraphFunction< > = async (ctx, authentication, params) => { const { bypassShortnameValidation, - shortname, + shortname: rawShortname, name, websiteUrl, machineEntityTypeVersion, orgEntityTypeVersion, } = params; + // Normalize shortname to lowercase for case-insensitive uniqueness + const shortname = rawShortname.toLowerCase(); + if (!bypassShortnameValidation && shortnameIsInvalid({ shortname })) { throw new Error(`The shortname "${shortname}" is invalid`); } diff --git a/apps/hash-api/src/graph/knowledge/system-types/user.ts b/apps/hash-api/src/graph/knowledge/system-types/user.ts index 9fb7b5e39bc..4e16027529a 100644 --- a/apps/hash-api/src/graph/knowledge/system-types/user.ts +++ b/apps/hash-api/src/graph/knowledge/system-types/user.ts @@ -361,12 +361,15 @@ export const createUser: ImpureGraphFunction< const { emails, kratosIdentityId, - shortname, + shortname: rawShortname, enabledFeatureFlags, displayName, isInstanceAdmin = false, } = params; + // Normalize shortname to lowercase for case-insensitive uniqueness + const shortname = rawShortname?.toLowerCase(); + const existingUserWithKratosIdentityId = await getUser(ctx, authentication, { kratosIdentityId, emails, diff --git a/libs/@local/graph/authorization/src/policies/store/mod.rs b/libs/@local/graph/authorization/src/policies/store/mod.rs index 94420fb68f9..dd1f65df933 100644 --- a/libs/@local/graph/authorization/src/policies/store/mod.rs +++ b/libs/@local/graph/authorization/src/policies/store/mod.rs @@ -792,11 +792,13 @@ impl OldPolicyStore for MemoryPolicyStore { fn create_web(&mut self, shortname: Option) -> Result> { let web_id = WebId::new(Uuid::new_v4()); + // Normalize shortname to lowercase for case-insensitive uniqueness + let normalized_shortname = shortname.map(|s| s.to_lowercase()); self.teams.insert( ActorGroupId::Web(web_id), ActorGroup::Web(Web { id: web_id, - shortname, + shortname: normalized_shortname, roles: HashSet::new(), }), ); diff --git a/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql b/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql new file mode 100644 index 00000000000..753c6cb6e36 --- /dev/null +++ b/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql @@ -0,0 +1,9 @@ +-- Make shortname lookups case-insensitive +-- This fixes a critical bug where @Timd and @timd could be two different users + +-- Drop the existing case-sensitive unique index +DROP INDEX IF EXISTS idx_web_shortname; + +-- Create a case-insensitive unique index using LOWER() +-- This ensures that 'Timd', 'timd', and 'TIMD' all conflict with each other +CREATE UNIQUE INDEX idx_web_shortname ON web (LOWER(shortname)) WHERE shortname IS NOT NULL; diff --git a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs index 93c0d7f2d39..72e5f64643d 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs @@ -852,11 +852,13 @@ where .await .change_context(WebCreationError::StoreError)?; + // Normalize shortname to lowercase for case-insensitive uniqueness + let normalized_shortname = parameter.shortname.as_ref().map(|s| s.to_lowercase()); transaction .as_client() .execute( "INSERT INTO web (id, shortname) VALUES ($1, $2)", - &[&web_id, ¶meter.shortname], + &[&web_id, &normalized_shortname], ) .instrument(tracing::info_span!( "INSERT", @@ -3898,6 +3900,8 @@ impl AccountStore for PostgresStore { id: WebId, shortname: &str, ) -> Result<(), Report> { + // Normalize shortname to lowercase for case-insensitive uniqueness + let normalized_shortname = shortname.to_lowercase(); let rows_affected = self .as_client() .execute( @@ -3906,7 +3910,7 @@ impl AccountStore for PostgresStore { SET shortname = $2 WHERE id = $1 ", - &[&id, &shortname], + &[&id, &normalized_shortname], ) .instrument(tracing::info_span!( "UPDATE", @@ -3936,6 +3940,8 @@ impl AccountStore for PostgresStore { _actor_id: ActorEntityUuid, shortname: &str, ) -> Result, Report> { + // Normalize shortname to lowercase for case-insensitive lookup + let normalized_shortname = shortname.to_lowercase(); Ok(self .as_client() .query_opt( @@ -3948,7 +3954,7 @@ impl AccountStore for PostgresStore { WHERE web.shortname = $1 GROUP BY web.id ", - &[&shortname], + &[&normalized_shortname], ) .instrument(tracing::info_span!( "SELECT", From e611bc59d4b9eb893c01b21636aff9151e1010cf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 11:01:36 +0000 Subject: [PATCH 02/10] BE-491: Refine case-insensitive shortnames implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move normalization to the database layer: add a trigger on the web table that lowercases shortnames on INSERT/UPDATE, and migrate existing data to lowercase - Remove redundant normalization from TypeScript callers (user.ts, org.ts, hook callback) – the graph handles it - Keep query-side normalization in Rust (get_web_by_shortname) since stored values are lowercase - Make shortnameIsRestricted handle case internally - Use case-insensitive comparison in the user update hook - Add integration tests verifying case-insensitive lookup and uniqueness for both users and orgs https://claude.ai/code/session_01JQNdEYEowZ22Vj3bTjSVGD --- ...user-before-update-entity-hook-callback.ts | 13 +++----- .../knowledge/system-types/account.fields.ts | 7 ++--- .../src/graph/knowledge/system-types/org.ts | 5 +-- .../src/graph/knowledge/system-types/user.ts | 5 +-- .../authorization/src/policies/store/mod.rs | 4 +-- .../V49__case_insensitive_shortname.sql | 22 ++++++++++--- .../postgres-store/src/store/postgres/mod.rs | 9 ++---- .../graph/knowledge/system-types/org.test.ts | 22 +++++++++++++ .../graph/knowledge/system-types/user.test.ts | 31 +++++++++++++++++++ 9 files changed, 82 insertions(+), 36 deletions(-) diff --git a/apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts b/apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts index c30292e7382..052466fc221 100644 --- a/apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts +++ b/apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts @@ -163,13 +163,9 @@ export const userBeforeEntityUpdateHookCallback: BeforeUpdateEntityHookCallback ); if (updatedShortname) { - // Normalize shortnames to lowercase for case-insensitive comparison - const normalizedUpdatedShortname = updatedShortname.toLowerCase(); - const normalizedCurrentShortname = currentShortname?.toLowerCase(); - if ( - normalizedCurrentShortname && - normalizedCurrentShortname !== normalizedUpdatedShortname + currentShortname && + currentShortname.toLowerCase() !== updatedShortname.toLowerCase() ) { throw Error.badUserInput("Cannot change shortname"); } @@ -178,7 +174,7 @@ export const userBeforeEntityUpdateHookCallback: BeforeUpdateEntityHookCallback await validateAccountShortname( context, { actorId: user.accountId }, - normalizedUpdatedShortname, + updatedShortname, ); } } @@ -232,8 +228,7 @@ export const userBeforeEntityUpdateHookCallback: BeforeUpdateEntityHookCallback await updateWebShortname( context.graphApi, { actorId: systemAccountId }, - // Normalize shortname to lowercase for case-insensitive uniqueness - { webId: user.accountId as WebId, shortname: updatedShortname.toLowerCase() }, + { webId: user.accountId as WebId, shortname: updatedShortname }, ); } }; diff --git a/apps/hash-api/src/graph/knowledge/system-types/account.fields.ts b/apps/hash-api/src/graph/knowledge/system-types/account.fields.ts index 879d41baf07..987fcff6943 100644 --- a/apps/hash-api/src/graph/knowledge/system-types/account.fields.ts +++ b/apps/hash-api/src/graph/knowledge/system-types/account.fields.ts @@ -107,7 +107,6 @@ export const shortnameIsRestricted: PureGraphFunction< { shortname: string }, boolean > = ({ shortname }): boolean => { - // Case-insensitive check: restricted shortnames are all lowercase return RESTRICTED_SHORTNAMES.includes(shortname.toLowerCase()); }; @@ -124,11 +123,9 @@ export const shortnameIsTaken: ImpureGraphFunction< * * @see https://linear.app/hash/issue/H-2989 */ - // Normalize shortname to lowercase for case-insensitive check - const normalizedParams = { shortname: params.shortname.toLowerCase() }; return ( - (await getUser(ctx, authentication, normalizedParams)) !== null || - (await getOrgByShortname(ctx, authentication, normalizedParams)) !== null + (await getUser(ctx, authentication, params)) !== null || + (await getOrgByShortname(ctx, authentication, params)) !== null ); }; diff --git a/apps/hash-api/src/graph/knowledge/system-types/org.ts b/apps/hash-api/src/graph/knowledge/system-types/org.ts index 44759906452..86ea139b682 100644 --- a/apps/hash-api/src/graph/knowledge/system-types/org.ts +++ b/apps/hash-api/src/graph/knowledge/system-types/org.ts @@ -119,16 +119,13 @@ export const createOrg: ImpureGraphFunction< > = async (ctx, authentication, params) => { const { bypassShortnameValidation, - shortname: rawShortname, + shortname, name, websiteUrl, machineEntityTypeVersion, orgEntityTypeVersion, } = params; - // Normalize shortname to lowercase for case-insensitive uniqueness - const shortname = rawShortname.toLowerCase(); - if (!bypassShortnameValidation && shortnameIsInvalid({ shortname })) { throw new Error(`The shortname "${shortname}" is invalid`); } diff --git a/apps/hash-api/src/graph/knowledge/system-types/user.ts b/apps/hash-api/src/graph/knowledge/system-types/user.ts index 4e16027529a..9fb7b5e39bc 100644 --- a/apps/hash-api/src/graph/knowledge/system-types/user.ts +++ b/apps/hash-api/src/graph/knowledge/system-types/user.ts @@ -361,15 +361,12 @@ export const createUser: ImpureGraphFunction< const { emails, kratosIdentityId, - shortname: rawShortname, + shortname, enabledFeatureFlags, displayName, isInstanceAdmin = false, } = params; - // Normalize shortname to lowercase for case-insensitive uniqueness - const shortname = rawShortname?.toLowerCase(); - const existingUserWithKratosIdentityId = await getUser(ctx, authentication, { kratosIdentityId, emails, diff --git a/libs/@local/graph/authorization/src/policies/store/mod.rs b/libs/@local/graph/authorization/src/policies/store/mod.rs index dd1f65df933..94420fb68f9 100644 --- a/libs/@local/graph/authorization/src/policies/store/mod.rs +++ b/libs/@local/graph/authorization/src/policies/store/mod.rs @@ -792,13 +792,11 @@ impl OldPolicyStore for MemoryPolicyStore { fn create_web(&mut self, shortname: Option) -> Result> { let web_id = WebId::new(Uuid::new_v4()); - // Normalize shortname to lowercase for case-insensitive uniqueness - let normalized_shortname = shortname.map(|s| s.to_lowercase()); self.teams.insert( ActorGroupId::Web(web_id), ActorGroup::Web(Web { id: web_id, - shortname: normalized_shortname, + shortname, roles: HashSet::new(), }), ); diff --git a/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql b/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql index 753c6cb6e36..ba75c6a13c1 100644 --- a/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql +++ b/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql @@ -1,9 +1,23 @@ --- Make shortname lookups case-insensitive --- This fixes a critical bug where @Timd and @timd could be two different users +-- Normalize existing shortnames to lowercase +UPDATE web SET shortname = LOWER(shortname) WHERE shortname IS NOT NULL AND shortname <> LOWER(shortname); -- Drop the existing case-sensitive unique index DROP INDEX IF EXISTS idx_web_shortname; --- Create a case-insensitive unique index using LOWER() --- This ensures that 'Timd', 'timd', and 'TIMD' all conflict with each other +-- Create a case-insensitive unique index CREATE UNIQUE INDEX idx_web_shortname ON web (LOWER(shortname)) WHERE shortname IS NOT NULL; + +-- Trigger to normalize shortnames to lowercase on insert/update +CREATE FUNCTION normalize_web_shortname() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.shortname IS NOT NULL THEN + NEW.shortname := LOWER(NEW.shortname); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER web_normalize_shortname_trigger +BEFORE INSERT OR UPDATE OF shortname ON web +FOR EACH ROW EXECUTE FUNCTION normalize_web_shortname(); diff --git a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs index 72e5f64643d..ff3d1dd7409 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs @@ -852,13 +852,11 @@ where .await .change_context(WebCreationError::StoreError)?; - // Normalize shortname to lowercase for case-insensitive uniqueness - let normalized_shortname = parameter.shortname.as_ref().map(|s| s.to_lowercase()); transaction .as_client() .execute( "INSERT INTO web (id, shortname) VALUES ($1, $2)", - &[&web_id, &normalized_shortname], + &[&web_id, ¶meter.shortname], ) .instrument(tracing::info_span!( "INSERT", @@ -3900,8 +3898,6 @@ impl AccountStore for PostgresStore { id: WebId, shortname: &str, ) -> Result<(), Report> { - // Normalize shortname to lowercase for case-insensitive uniqueness - let normalized_shortname = shortname.to_lowercase(); let rows_affected = self .as_client() .execute( @@ -3910,7 +3906,7 @@ impl AccountStore for PostgresStore { SET shortname = $2 WHERE id = $1 ", - &[&id, &normalized_shortname], + &[&id, &shortname], ) .instrument(tracing::info_span!( "UPDATE", @@ -3940,7 +3936,6 @@ impl AccountStore for PostgresStore { _actor_id: ActorEntityUuid, shortname: &str, ) -> Result, Report> { - // Normalize shortname to lowercase for case-insensitive lookup let normalized_shortname = shortname.to_lowercase(); Ok(self .as_client() diff --git a/tests/hash-backend-integration/src/tests/graph/knowledge/system-types/org.test.ts b/tests/hash-backend-integration/src/tests/graph/knowledge/system-types/org.test.ts index 055b32b82e0..9c6cd31ccfc 100644 --- a/tests/hash-backend-integration/src/tests/graph/knowledge/system-types/org.test.ts +++ b/tests/hash-backend-integration/src/tests/graph/knowledge/system-types/org.test.ts @@ -1,6 +1,7 @@ import { ensureSystemGraphIsInitialized } from "@apps/hash-api/src/graph/ensure-system-graph-is-initialized"; import type { Org } from "@apps/hash-api/src/graph/knowledge/system-types/org"; import { + createOrg, getOrgByShortname, updateOrgName, } from "@apps/hash-api/src/graph/knowledge/system-types/org"; @@ -68,6 +69,27 @@ describe("Org", () => { expect(fetchedOrg).toEqual(createdOrg); }); + it("can get an org by its shortname with different casing", async () => { + const authentication = { actorId: systemAccountId }; + + const fetchedOrg = await getOrgByShortname(graphContext, authentication, { + shortname: shortname.toUpperCase(), + }); + + expect(fetchedOrg).toEqual(createdOrg); + }); + + it("cannot create an org with a shortname differing only in case", async () => { + const authentication = { actorId: systemAccountId }; + + await expect( + createOrg(graphContext, authentication, { + name: "Case test org", + shortname: shortname.toUpperCase(), + }), + ).rejects.toThrowError("already exists"); + }); + it("can read the org roles", async () => { const authentication = { actorId: systemAccountId }; diff --git a/tests/hash-backend-integration/src/tests/graph/knowledge/system-types/user.test.ts b/tests/hash-backend-integration/src/tests/graph/knowledge/system-types/user.test.ts index 013304ad820..27ea4e4effd 100644 --- a/tests/hash-backend-integration/src/tests/graph/knowledge/system-types/user.test.ts +++ b/tests/hash-backend-integration/src/tests/graph/knowledge/system-types/user.test.ts @@ -143,6 +143,37 @@ describe("User model class", () => { expect(fetchedUser).toEqual(createdUser); }); + it("can get a user by its shortname with different casing", async () => { + const authentication = { actorId: createdUser.accountId }; + + const fetchedUser = await getUser(graphContext, authentication, { + shortname: shortname.toUpperCase(), + }); + + expect(fetchedUser).not.toBeNull(); + expect(fetchedUser).toEqual(createdUser); + }); + + it("cannot create a user with a shortname differing only in case", async () => { + const authentication = { actorId: systemAccountId }; + + const identity = await createKratosIdentity({ + traits: { + emails: ["case-test-user@example.com"], + }, + verifyEmails: true, + }); + + await expect( + createUser(graphContext, authentication, { + emails: ["case-test-user@example.com"], + kratosIdentityId: identity.id, + shortname: shortname.toUpperCase(), + displayName: "Case Test", + }), + ).rejects.toThrowError("already exists"); + }); + it("can get a user by its kratos identity id", async () => { const authentication = { actorId: createdUser.accountId }; From bfd6db01bcb5763651f16aa4591bcc28f3b78713 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 11:11:17 +0000 Subject: [PATCH 03/10] BE-491: Update both migration systems for case-insensitive shortnames - Update graph-migrations v002__principals with case-insensitive shortname index and normalization trigger - Keep postgres_migrations V49 for existing databases with V43 applied https://claude.ai/code/session_01JQNdEYEowZ22Vj3bTjSVGD --- .../graph-migrations/v002__principals/down.sql | 2 ++ .../graph-migrations/v002__principals/up.sql | 18 +++++++++++++++++- .../V49__case_insensitive_shortname.sql | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/libs/@local/graph/migrations/graph-migrations/v002__principals/down.sql b/libs/@local/graph/migrations/graph-migrations/v002__principals/down.sql index 61b3de91ed0..b9e05eba9c3 100644 --- a/libs/@local/graph/migrations/graph-migrations/v002__principals/down.sql +++ b/libs/@local/graph/migrations/graph-migrations/v002__principals/down.sql @@ -34,7 +34,9 @@ DROP TABLE team; DROP TRIGGER web_prevent_delete_trigger ON web; DROP TRIGGER web_register_trigger ON web; +DROP TRIGGER web_normalize_shortname_trigger ON web; DROP FUNCTION register_web(); +DROP FUNCTION normalize_web_shortname(); DROP TABLE web; -- Drop actor group triggers diff --git a/libs/@local/graph/migrations/graph-migrations/v002__principals/up.sql b/libs/@local/graph/migrations/graph-migrations/v002__principals/up.sql index 7b581480ec3..64b96868493 100644 --- a/libs/@local/graph/migrations/graph-migrations/v002__principals/up.sql +++ b/libs/@local/graph/migrations/graph-migrations/v002__principals/up.sql @@ -258,7 +258,23 @@ CREATE TABLE web ( shortname TEXT ); -CREATE UNIQUE INDEX idx_web_shortname ON web (shortname) WHERE shortname IS NOT NULL; +-- Case-insensitive unique index for shortnames +CREATE UNIQUE INDEX idx_web_shortname ON web (LOWER(shortname)) WHERE shortname IS NOT NULL; + +-- Trigger to normalize shortnames to lowercase on insert/update +CREATE FUNCTION normalize_web_shortname() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.shortname IS NOT NULL THEN + NEW.shortname := LOWER(NEW.shortname); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER web_normalize_shortname_trigger +BEFORE INSERT OR UPDATE OF shortname ON web +FOR EACH ROW EXECUTE FUNCTION normalize_web_shortname(); -- Web registration trigger - creates actor group record when web is created CREATE FUNCTION register_web() diff --git a/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql b/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql index ba75c6a13c1..0791bbbf597 100644 --- a/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql +++ b/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql @@ -4,7 +4,7 @@ UPDATE web SET shortname = LOWER(shortname) WHERE shortname IS NOT NULL AND shor -- Drop the existing case-sensitive unique index DROP INDEX IF EXISTS idx_web_shortname; --- Create a case-insensitive unique index +-- Case-insensitive unique index for shortnames CREATE UNIQUE INDEX idx_web_shortname ON web (LOWER(shortname)) WHERE shortname IS NOT NULL; -- Trigger to normalize shortnames to lowercase on insert/update From ded046f27917e051582c90ec1cd78360bdc24340 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 11:13:39 +0000 Subject: [PATCH 04/10] BE-491: Use LOWER($1) in SQL instead of Rust normalization Let the database handle case normalization in the query. https://claude.ai/code/session_01JQNdEYEowZ22Vj3bTjSVGD --- libs/@local/graph/postgres-store/src/store/postgres/mod.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs index ff3d1dd7409..aa942d17e39 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs @@ -3936,7 +3936,6 @@ impl AccountStore for PostgresStore { _actor_id: ActorEntityUuid, shortname: &str, ) -> Result, Report> { - let normalized_shortname = shortname.to_lowercase(); Ok(self .as_client() .query_opt( @@ -3946,10 +3945,10 @@ impl AccountStore for PostgresStore { array_remove(array_agg(role.id), NULL) FROM web LEFT OUTER JOIN role ON web.id = role.actor_group_id - WHERE web.shortname = $1 + WHERE web.shortname = LOWER($1) GROUP BY web.id ", - &[&normalized_shortname], + &[&shortname], ) .instrument(tracing::info_span!( "SELECT", From 7ef341373caeba569fb1ff042632491f8f13dd93 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Wed, 1 Apr 2026 11:58:40 +0100 Subject: [PATCH 05/10] add more lowercase handling --- .../hash-api/src/graph/knowledge/system-types/org.ts | 4 ++-- .../src/graph/knowledge/system-types/user.ts | 2 +- libs/@local/graph/sdk/typescript/src/entity.ts | 12 ++++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/hash-api/src/graph/knowledge/system-types/org.ts b/apps/hash-api/src/graph/knowledge/system-types/org.ts index 86ea139b682..ccc7952ebc3 100644 --- a/apps/hash-api/src/graph/knowledge/system-types/org.ts +++ b/apps/hash-api/src/graph/knowledge/system-types/org.ts @@ -190,7 +190,7 @@ export const createOrg: ImpureGraphFunction< const properties: OrganizationPropertiesWithMetadata = { value: { "https://hash.ai/@h/types/property-type/shortname/": { - value: shortname, + value: shortname.trim().toLowerCase(), metadata: { dataTypeId: "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1", @@ -319,7 +319,7 @@ export const getOrgByShortname: ImpureGraphFunction< systemPropertyTypes.shortname.propertyTypeBaseUrl, ], }, - { parameter: params.shortname }, + { parameter: params.shortname.trim().toLowerCase() }, ], }, ], diff --git a/apps/hash-api/src/graph/knowledge/system-types/user.ts b/apps/hash-api/src/graph/knowledge/system-types/user.ts index 9fb7b5e39bc..606fbe40fe6 100644 --- a/apps/hash-api/src/graph/knowledge/system-types/user.ts +++ b/apps/hash-api/src/graph/knowledge/system-types/user.ts @@ -264,7 +264,7 @@ export const getUser: ImpureGraphFunction< systemPropertyTypes.shortname.propertyTypeBaseUrl, ], }, - { parameter: knownShortname }, + { parameter: knownShortname?.trim().toLowerCase() }, ], }; } diff --git a/libs/@local/graph/sdk/typescript/src/entity.ts b/libs/@local/graph/sdk/typescript/src/entity.ts index 0d318f26b0e..2610ae2ee0a 100644 --- a/libs/@local/graph/sdk/typescript/src/entity.ts +++ b/libs/@local/graph/sdk/typescript/src/entity.ts @@ -1228,6 +1228,18 @@ export class HashEntity< } } } + + if (targetBaseUrl === shortnamePropertyBaseUrl) { + if (patch.op === "remove") { + throw new Error("Cannot remove the shortname of a user"); + } + + if (typeof patch.property.value !== "string") { + throw new Error("Shortname must be a string"); + } + + patch.property.value = patch.property.value.trim().toLowerCase(); + } } } From 8a250673efdb612b15ac1aab7e822a17332b9be5 Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Wed, 1 Apr 2026 13:40:46 +0200 Subject: [PATCH 06/10] BE-491: Normalize shortnames with trim, fix createUser and get_web_by_shortname - Normalize shortname in createUser entity property (was missing, unlike createOrg) - Add TRIM() to DB trigger and migration for defense-in-depth - Read shortname from DB in get_web_by_shortname instead of echoing input - Add TRIM($1) to Rust shortname lookup query - Add whitespace trimming tests for user and org lookups --- .../hash-api/src/graph/knowledge/system-types/user.ts | 2 +- .../graph-migrations/v002__principals/up.sql | 4 ++-- .../V49__case_insensitive_shortname.sql | 4 ++-- .../graph/postgres-store/src/store/postgres/mod.rs | 9 +++++---- .../tests/graph/knowledge/system-types/org.test.ts | 10 ++++++++++ .../tests/graph/knowledge/system-types/user.test.ts | 11 +++++++++++ 6 files changed, 31 insertions(+), 9 deletions(-) diff --git a/apps/hash-api/src/graph/knowledge/system-types/user.ts b/apps/hash-api/src/graph/knowledge/system-types/user.ts index 606fbe40fe6..b2e905793e3 100644 --- a/apps/hash-api/src/graph/knowledge/system-types/user.ts +++ b/apps/hash-api/src/graph/knowledge/system-types/user.ts @@ -430,7 +430,7 @@ export const createUser: ImpureGraphFunction< ...(shortname !== undefined ? { "https://hash.ai/@h/types/property-type/shortname/": { - value: shortname, + value: shortname.trim().toLowerCase(), metadata: { dataTypeId: "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1", diff --git a/libs/@local/graph/migrations/graph-migrations/v002__principals/up.sql b/libs/@local/graph/migrations/graph-migrations/v002__principals/up.sql index 64b96868493..2a4db382c09 100644 --- a/libs/@local/graph/migrations/graph-migrations/v002__principals/up.sql +++ b/libs/@local/graph/migrations/graph-migrations/v002__principals/up.sql @@ -259,14 +259,14 @@ CREATE TABLE web ( ); -- Case-insensitive unique index for shortnames -CREATE UNIQUE INDEX idx_web_shortname ON web (LOWER(shortname)) WHERE shortname IS NOT NULL; +CREATE UNIQUE INDEX idx_web_shortname ON web (lower(shortname)) WHERE shortname IS NOT NULL; -- Trigger to normalize shortnames to lowercase on insert/update CREATE FUNCTION normalize_web_shortname() RETURNS TRIGGER AS $$ BEGIN IF NEW.shortname IS NOT NULL THEN - NEW.shortname := LOWER(NEW.shortname); + NEW.shortname := LOWER(TRIM(NEW.shortname)); END IF; RETURN NEW; END; diff --git a/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql b/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql index 0791bbbf597..69001da27fe 100644 --- a/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql +++ b/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql @@ -1,5 +1,5 @@ -- Normalize existing shortnames to lowercase -UPDATE web SET shortname = LOWER(shortname) WHERE shortname IS NOT NULL AND shortname <> LOWER(shortname); +UPDATE web SET shortname = LOWER(TRIM(shortname)) WHERE shortname IS NOT NULL AND shortname <> LOWER(TRIM(shortname)); -- Drop the existing case-sensitive unique index DROP INDEX IF EXISTS idx_web_shortname; @@ -12,7 +12,7 @@ CREATE FUNCTION normalize_web_shortname() RETURNS TRIGGER AS $$ BEGIN IF NEW.shortname IS NOT NULL THEN - NEW.shortname := LOWER(NEW.shortname); + NEW.shortname := LOWER(TRIM(NEW.shortname)); END IF; RETURN NEW; END; diff --git a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs index aa942d17e39..5fd8a492611 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs @@ -3942,11 +3942,12 @@ impl AccountStore for PostgresStore { " SELECT web.id, + web.shortname, array_remove(array_agg(role.id), NULL) FROM web LEFT OUTER JOIN role ON web.id = role.actor_group_id - WHERE web.shortname = LOWER($1) - GROUP BY web.id + WHERE web.shortname = LOWER(TRIM($1)) + GROUP BY web.id, web.shortname ", &[&shortname], ) @@ -3959,10 +3960,10 @@ impl AccountStore for PostgresStore { .await .change_context(WebRetrievalError)? .map(|row| { - let role_ids = row.get::<_, Vec>(1); + let role_ids = row.get::<_, Vec>(2); Web { id: row.get(0), - shortname: Some(shortname.to_owned()), + shortname: row.get(1), roles: role_ids.into_iter().collect(), } })) diff --git a/tests/hash-backend-integration/src/tests/graph/knowledge/system-types/org.test.ts b/tests/hash-backend-integration/src/tests/graph/knowledge/system-types/org.test.ts index 9c6cd31ccfc..a9db127480d 100644 --- a/tests/hash-backend-integration/src/tests/graph/knowledge/system-types/org.test.ts +++ b/tests/hash-backend-integration/src/tests/graph/knowledge/system-types/org.test.ts @@ -90,6 +90,16 @@ describe("Org", () => { ).rejects.toThrowError("already exists"); }); + it("can get an org by its shortname with leading/trailing whitespace", async () => { + const authentication = { actorId: systemAccountId }; + + const fetchedOrg = await getOrgByShortname(graphContext, authentication, { + shortname: ` ${shortname} `, + }); + + expect(fetchedOrg).toEqual(createdOrg); + }); + it("can read the org roles", async () => { const authentication = { actorId: systemAccountId }; diff --git a/tests/hash-backend-integration/src/tests/graph/knowledge/system-types/user.test.ts b/tests/hash-backend-integration/src/tests/graph/knowledge/system-types/user.test.ts index 27ea4e4effd..7cb067c3b64 100644 --- a/tests/hash-backend-integration/src/tests/graph/knowledge/system-types/user.test.ts +++ b/tests/hash-backend-integration/src/tests/graph/knowledge/system-types/user.test.ts @@ -174,6 +174,17 @@ describe("User model class", () => { ).rejects.toThrowError("already exists"); }); + it("can get a user by its shortname with leading/trailing whitespace", async () => { + const authentication = { actorId: createdUser.accountId }; + + const fetchedUser = await getUser(graphContext, authentication, { + shortname: ` ${shortname} `, + }); + + expect(fetchedUser).not.toBeNull(); + expect(fetchedUser).toEqual(createdUser); + }); + it("can get a user by its kratos identity id", async () => { const authentication = { actorId: createdUser.accountId }; From 68d6f7bd54f8697051e03eeaed99f20a37d94953 Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Wed, 1 Apr 2026 13:42:26 +0200 Subject: [PATCH 07/10] BE-491: Use lowercase SQL function names per sqlfluff CP03 --- .../migrations/graph-migrations/v002__principals/up.sql | 2 +- .../postgres_migrations/V49__case_insensitive_shortname.sql | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/@local/graph/migrations/graph-migrations/v002__principals/up.sql b/libs/@local/graph/migrations/graph-migrations/v002__principals/up.sql index 2a4db382c09..ffc49a2a51f 100644 --- a/libs/@local/graph/migrations/graph-migrations/v002__principals/up.sql +++ b/libs/@local/graph/migrations/graph-migrations/v002__principals/up.sql @@ -266,7 +266,7 @@ CREATE FUNCTION normalize_web_shortname() RETURNS TRIGGER AS $$ BEGIN IF NEW.shortname IS NOT NULL THEN - NEW.shortname := LOWER(TRIM(NEW.shortname)); + NEW.shortname := lower(trim(NEW.shortname)); END IF; RETURN NEW; END; diff --git a/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql b/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql index 69001da27fe..e1cab12e5e8 100644 --- a/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql +++ b/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql @@ -1,18 +1,18 @@ -- Normalize existing shortnames to lowercase -UPDATE web SET shortname = LOWER(TRIM(shortname)) WHERE shortname IS NOT NULL AND shortname <> LOWER(TRIM(shortname)); +UPDATE web SET shortname = lower(trim(shortname)) WHERE shortname IS NOT NULL AND shortname <> lower(trim(shortname)); -- Drop the existing case-sensitive unique index DROP INDEX IF EXISTS idx_web_shortname; -- Case-insensitive unique index for shortnames -CREATE UNIQUE INDEX idx_web_shortname ON web (LOWER(shortname)) WHERE shortname IS NOT NULL; +CREATE UNIQUE INDEX idx_web_shortname ON web (lower(shortname)) WHERE shortname IS NOT NULL; -- Trigger to normalize shortnames to lowercase on insert/update CREATE FUNCTION normalize_web_shortname() RETURNS TRIGGER AS $$ BEGIN IF NEW.shortname IS NOT NULL THEN - NEW.shortname := LOWER(TRIM(NEW.shortname)); + NEW.shortname := lower(trim(NEW.shortname)); END IF; RETURN NEW; END; From b1adc64afaca7a5efd1d55f07b182ea9206f7e76 Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Wed, 1 Apr 2026 13:50:06 +0200 Subject: [PATCH 08/10] BE-491: Keep plain shortname index, trigger handles normalization Expression index on LOWER(shortname) is unnecessary when the trigger already normalizes values and prevents the query planner from using the index with bare column comparisons. --- .../migrations/graph-migrations/v002__principals/up.sql | 3 +-- .../V49__case_insensitive_shortname.sql | 8 +------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/libs/@local/graph/migrations/graph-migrations/v002__principals/up.sql b/libs/@local/graph/migrations/graph-migrations/v002__principals/up.sql index ffc49a2a51f..434dea86b6e 100644 --- a/libs/@local/graph/migrations/graph-migrations/v002__principals/up.sql +++ b/libs/@local/graph/migrations/graph-migrations/v002__principals/up.sql @@ -258,8 +258,7 @@ CREATE TABLE web ( shortname TEXT ); --- Case-insensitive unique index for shortnames -CREATE UNIQUE INDEX idx_web_shortname ON web (lower(shortname)) WHERE shortname IS NOT NULL; +CREATE UNIQUE INDEX idx_web_shortname ON web (shortname) WHERE shortname IS NOT NULL; -- Trigger to normalize shortnames to lowercase on insert/update CREATE FUNCTION normalize_web_shortname() diff --git a/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql b/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql index e1cab12e5e8..c587d3679f8 100644 --- a/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql +++ b/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql @@ -1,12 +1,6 @@ --- Normalize existing shortnames to lowercase +-- Normalize existing shortnames to lowercase and trimmed UPDATE web SET shortname = lower(trim(shortname)) WHERE shortname IS NOT NULL AND shortname <> lower(trim(shortname)); --- Drop the existing case-sensitive unique index -DROP INDEX IF EXISTS idx_web_shortname; - --- Case-insensitive unique index for shortnames -CREATE UNIQUE INDEX idx_web_shortname ON web (lower(shortname)) WHERE shortname IS NOT NULL; - -- Trigger to normalize shortnames to lowercase on insert/update CREATE FUNCTION normalize_web_shortname() RETURNS TRIGGER AS $$ From 5528438b6ae80ae068d8bbaf5224e87138c5edb6 Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Wed, 1 Apr 2026 13:54:45 +0200 Subject: [PATCH 09/10] BE-491: Reorder migration to create trigger before normalizing data --- .../V49__case_insensitive_shortname.sql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql b/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql index c587d3679f8..756d6fd2be8 100644 --- a/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql +++ b/libs/@local/graph/postgres-store/postgres_migrations/V49__case_insensitive_shortname.sql @@ -1,7 +1,4 @@ --- Normalize existing shortnames to lowercase and trimmed -UPDATE web SET shortname = lower(trim(shortname)) WHERE shortname IS NOT NULL AND shortname <> lower(trim(shortname)); - --- Trigger to normalize shortnames to lowercase on insert/update +-- Trigger to normalize shortnames to lowercase and trimmed on insert/update CREATE FUNCTION normalize_web_shortname() RETURNS TRIGGER AS $$ BEGIN @@ -15,3 +12,6 @@ $$ LANGUAGE plpgsql; CREATE TRIGGER web_normalize_shortname_trigger BEFORE INSERT OR UPDATE OF shortname ON web FOR EACH ROW EXECUTE FUNCTION normalize_web_shortname(); + +-- Normalize existing shortnames +UPDATE web SET shortname = lower(trim(shortname)) WHERE shortname IS NOT NULL AND shortname <> lower(trim(shortname)); From 9970da26dfecdcafa29723c7ffb92435fe413660 Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Wed, 1 Apr 2026 14:44:57 +0200 Subject: [PATCH 10/10] BE-491: Normalize org shortname patch before comparison in SDK handler --- libs/@local/graph/sdk/typescript/src/entity.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/libs/@local/graph/sdk/typescript/src/entity.ts b/libs/@local/graph/sdk/typescript/src/entity.ts index 2610ae2ee0a..b0c474c160f 100644 --- a/libs/@local/graph/sdk/typescript/src/entity.ts +++ b/libs/@local/graph/sdk/typescript/src/entity.ts @@ -1261,11 +1261,17 @@ export class HashEntity< throw new Error("Cannot remove the organization shortname"); } - if ( - patch.property.value !== this.properties[shortnamePropertyBaseUrl] - ) { + if (typeof patch.property.value !== "string") { + throw new Error("Shortname must be a string"); + } + + const normalizedPatch = patch.property.value.trim().toLowerCase(); + const stored = this.properties[shortnamePropertyBaseUrl]; + if (normalizedPatch !== stored) { throw new Error("Cannot change the shortname of an organization"); } + + patch.property.value = normalizedPatch; } if (patch.path[0] === organizationNamePropertyBaseUrl) {