Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
840758b
Add Spar.Scim.Group module.
fisx Oct 13, 2025
b822d07
Add postgresPool to Spar config
eyeinsky Oct 30, 2025
83e0035
fix: format
blackheaven Oct 31, 2025
5f0106c
fix: test & hlint
blackheaven Nov 1, 2025
994da9f
Fix credential paths in spar.integration.yaml.
fisx Nov 3, 2025
3570472
Fix paths in spar.integration.yaml
eyeinsky Oct 31, 2025
9554bb4
Fix json syntax in test.
fisx Nov 3, 2025
987e3fb
Get queue name from integration-user-events.fifo
eyeinsky Nov 3, 2025
4fc1efd
Fix journal queue url computation (for real this time?)
fisx Nov 3, 2025
9da14df
Gardening.
fisx Nov 3, 2025
2fe7c17
Fix unit test; remove forgotten `focus`.
fisx Nov 4, 2025
8ef035d
Fix: queue url is already ready in Env, no need to compute it here.
fisx Nov 4, 2025
ac6af4b
re-inline stuff.
fisx Nov 4, 2025
c4af06d
Implement formerly undefined error type converters.
fisx Nov 4, 2025
94a8754
Fixup
fisx Nov 4, 2025
3776fbe
Cleanup
fisx Nov 4, 2025
5bd03f0
refactor: move to brigapiaccess
blackheaven Nov 4, 2025
2cf977f
Make test case more readable (and less wrong).
fisx Nov 5, 2025
c743275
linting.
fisx Nov 5, 2025
a88fc72
Fix unit test.
fisx Nov 5, 2025
d255422
changelog.
fisx Nov 5, 2025
60d9b51
Simplify getAccountsBy internal end-point (no need to talk federation).
fisx Nov 5, 2025
6083ca5
fix: clean interpreters
blackheaven Nov 5, 2025
797766b
Fix integration test [WIP]
fisx Nov 5, 2025
3926d14
fix: clean config
blackheaven Nov 5, 2025
1d884be
Completely rewrite scim-post-group handler for readability.
fisx Nov 5, 2025
6876035
Fix integration test.
fisx Nov 5, 2025
1a6c529
refactor: merge spar BrigAccess to wire-subsystem BrigAPIAccess
blackheaven Nov 5, 2025
5518a5f
refactor: merge spar GalleyAccess to wire-subsystem GalleyAPIAccess
blackheaven Nov 5, 2025
21b5eda
Re-align galley rpc errors between spar and wire-subsystems.
fisx Nov 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog.d/5-internal/scim-create-group
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Introducing user groups in SCIM involved a lot of refactorings:
- some of Brig.CanonicalInterpreters has been moved (copied?) to wire-subsystems (notably stomp, aws, events)
- Spar.CanonicalInterpreter has been extended to make use of the subsystems formerly only used by brig (somme of the interpreters are undefined because unused)
- since that running brig code in spar wasn't always feasible: brig internal api has been extended to expose UserGroupSubsystem to spar
1 change: 1 addition & 0 deletions changelog.d/scim-create-group
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Create user groups with SCIM.
13 changes: 13 additions & 0 deletions charts/spar/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ data:
tlsCa: /etc/wire/spar/cassandra/{{- (include "tlsSecretRef" . | fromYaml).key }}
{{- end }}

elasticsearch:
url: {{ .elasticsearch.scheme }}://{{ .elasticsearch.host }}:{{ .elasticsearch.port }}
index: {{ .elasticsearch.index }}
insecureSkipVerifyTls: {{ .elasticsearch.insecureSkipVerifyTls }}
{{- if $.Values.secrets.elasticsearch }}
credentials: /etc/wire/spar/secrets/elasticsearch-credentials.yaml
{{- end }}
{{- if .elasticsearch.tlsCa }}
caCert: /etc/wire/spar/elasticsearch/ca.pem
{{- else if .elasticsearch.tlsCaSecretRef }}
caCert: /etc/wire/spar/elasticsearch/{{- .elasticsearch.tlsCaSecretRef.key }}
{{- end }}

maxttlAuthreq: {{ .maxttlAuthreq }}
maxttlAuthresp: {{ .maxttlAuthresp }}

Expand Down
15 changes: 15 additions & 0 deletions charts/spar/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ config:
# tlsCaSecretRef:
# name: <secret-name>
# key: <ca-attribute>

elasticsearch:
scheme: http
host: elasticsearch-client
port: 9200
index: directory_spar
insecureSkipVerifyTls: false
# To configure custom TLS CA, please provide one of these:
# tlsCa: <CA in PEM format (can be self-signed)>
#
# Or refer to an existing secret (containing the CA):
# tlsCaSecretRef:
# name: <secret-name>
# key: <ca-attribute>

richInfoLimit: 5000
maxScimTokens: 0
logLevel: Info
Expand Down
6 changes: 6 additions & 0 deletions integration/test/API/Spar.hs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ updateScimUser domain scimToken userId scimUser = do
& addJSON body . addHeader "Authorization" ("Bearer " <> scimToken)
& addHeader "Accept" "application/scim+json"

createScimUserGroup :: (HasCallStack, MakesValue domain, MakesValue scimUserGroup) => domain -> String -> scimUserGroup -> App Response
createScimUserGroup domain token scimUserGroup = do
req <- baseRequest domain Spar Versioned "/scim/v2/Groups"
body <- make scimUserGroup
submit "POST" $ req & addJSON body . addHeader "Authorization" ("Bearer " <> token)

-- | https://staging-nginz-https.zinfra.io/v12/api/swagger-ui/#/default/idp-create
createIdp :: (HasCallStack, MakesValue user) => user -> SAML.IdPMetadata -> App Response
createIdp user metadata = do
Expand Down
60 changes: 59 additions & 1 deletion integration/test/Test/Spar.hs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{-# OPTIONS_GHC -Wno-incomplete-patterns -Wno-ambiguous-fields #-}
{-# OPTIONS_GHC -Wno-incomplete-patterns -Wno-ambiguous-fields #-}

module Test.Spar where

Expand Down Expand Up @@ -362,6 +362,64 @@ testSparCreateScimTokenWithName = do
assoc <- token %. "id"
token %. "name" `shouldMatch` Just assoc

----------------------------------------------------------------------
-- scim group stuff

testSparScimCreateUserGroup :: (HasCallStack) => App ()
testSparScimCreateUserGroup = do
(owner, tid, _) <- createTeam OwnDomain 1
tok <- createScimTokenV6 owner def >>= \resp -> resp.json %. "token" >>= asString

let -- this function looks messy and may be overdoing it in the head
-- of the debate with the compiler. its only purpose is to make
-- a team member that satisfies all conditions for being added
-- to a scim group.
mkMemberCandidate :: App String
mkMemberCandidate = do
assertSuccess =<< setTeamFeatureStatus owner tid "validateSAMLemails" "disabled"
assertSuccess =<< setTeamFeatureStatus owner tid "sso" "enabled"
void $ registerTestIdPWithMetaWithPrivateCreds owner

scimUserEmail <- randomEmail
scimUser <- randomScimUserWith def {mkExternalId = pure scimUserEmail}
uid <- createScimUser owner tok scimUser >>= getJSON 201 >>= (%. "id") >>= asString
quid <- do
dom <- make OwnDomain >>= asString
pure $ object ["domain" .= dom, "id" .= uid]

getScimUser OwnDomain tok uid `bindResponse` \res -> do
res.status `shouldMatchInt` 200
res.json %. "id" `shouldMatch` uid

registerInvitedUser OwnDomain tid scimUserEmail

getSelf quid `bindResponse` \res -> do
res.status `shouldMatchInt` 200
res.json %. "id" `shouldMatch` uid
res.json %. "team" `shouldMatch` tid
res.json %. "status" `shouldMatch` "active"
res.json %. "managed_by" `shouldMatch` "scim"

pure uid

scimUserId <- mkMemberCandidate
let scimUserGroup =
object
[ "schemas" .= ["urn:ietf:params:scim:schemas:core:2.0:Group"],
"displayName" .= "ze groop",
"members"
.= [ object
[ "type" .= "User",
"$ref" .= "...", -- something like
-- "https://example.org/v2/scim/User/ea2e4bf0-aa5e-11f0-96ad-e776a606779b"?
-- but since we're just receiving this it's ok
-- to ignore.
"value" .= scimUserId
]
]
]
createScimUserGroup OwnDomain tok scimUserGroup >>= assertSuccess

----------------------------------------------------------------------
-- saml stuff

Expand Down
4 changes: 4 additions & 0 deletions libs/hscim/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@
, template-haskell
, text
, time
, utf8-string
, uuid
, wai
, wai-extra
, wai-utilities
, warp
}:
mkDerivation {
Expand Down Expand Up @@ -85,9 +87,11 @@ mkDerivation {
template-haskell
text
time
utf8-string
uuid
wai
wai-extra
wai-utilities
];
executableHaskellDepends = [
base
Expand Down
2 changes: 2 additions & 0 deletions libs/hscim/hscim.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,11 @@ library
, template-haskell
, text
, time
, utf8-string
, uuid
, wai
, wai-extra
, wai-utilities

default-language: Haskell2010

Expand Down
7 changes: 7 additions & 0 deletions libs/hscim/src/Web/Scim/Schema/Common.hs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import qualified Data.CaseInsensitive as CI
import Data.List (nub, (\\))
import Data.String.Conversions (cs)
import Data.Text (Text, pack, unpack)
import qualified Data.Text as Text
import qualified Network.URI as Network

data WithId id a = WithId
Expand All @@ -49,6 +50,12 @@ instance (FromJSON id, FromJSON a) => FromJSON (WithId id a) where
newtype URI = URI {unURI :: Network.URI}
deriving (Show, Eq)

uriToString :: URI -> String
uriToString = (\uri -> Network.uriToString Prelude.id uri "") . unURI

uriToText :: URI -> Text
uriToText = Text.pack . uriToString

instance FromJSON URI where
parseJSON = withText "URI" $ \uri -> case Network.parseURI (unpack uri) of
Nothing -> fail "Invalid URI"
Expand Down
18 changes: 17 additions & 1 deletion libs/hscim/src/Web/Scim/Schema/Error.hs
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,21 @@ module Web.Scim.Schema.Error
forbidden,
serverError,

-- * Servant interoperability
-- * Servant/Wai interoperability
scimToServerError,
scimToWaiError,
)
where

import Control.Exception
import Data.Aeson hiding (Error)
import Data.ByteString.UTF8 (fromString)
import Data.Text (Text, pack)
import qualified Data.Text.Lazy.Encoding as LText
import GHC.Generics (Generic)
import qualified Network.HTTP.Types.Header as HTTP
import qualified Network.HTTP.Types.Status as HTTP
import qualified Network.Wai.Utilities.Error as Wai
import Servant (ServerError (..))
import Web.Scim.Schema.Common
import Web.Scim.Schema.Schema
Expand Down Expand Up @@ -175,6 +181,16 @@ serverError details =
----------------------------------------------------------------------------
-- Servant

-- | Convert a SCIM 'Error' to a Servant one by encoding it with the
-- appropriate headers.
-- We would like to use Wire.Error.HttpError from wire-subsystems,
-- but hscim can't depend on that.
scimToWaiError :: ScimError -> (Wai.Error, [HTTP.Header])
scimToWaiError err = (Wai.mkError e "scim-error" (LText.decodeUtf8 $ encode err), hs)
where
e = HTTP.Status (unStatus (status err)) (fromString $ reasonPhrase (status err))
hs = [("Content-Type", "application/scim+json;charset=utf-8")]

-- | Convert a SCIM 'Error' to a Servant one by encoding it with the
-- appropriate headers.
scimToServerError :: ScimError -> ServerError
Expand Down
7 changes: 7 additions & 0 deletions libs/types-common/src/Data/HavePendingInvitations.hs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
module Data.HavePendingInvitations where

import Data.Aeson (FromJSON, ToJSON)
import Data.OpenApi qualified as S
import Data.Schema
import Imports
import Wire.Arbitrary

Expand All @@ -8,6 +11,10 @@ data HavePendingInvitations
| NoPendingInvitations
deriving (Eq, Show, Ord, Generic)
deriving (Arbitrary) via GenericUniform HavePendingInvitations
deriving (FromJSON, ToJSON, S.ToSchema) via Schema HavePendingInvitations

instance ToSchema HavePendingInvitations where
schema = enum @Bool "HavePendingInvitations" $ mconcat [element True WithPendingInvitations, element False NoPendingInvitations]

fromBool :: Bool -> HavePendingInvitations
fromBool True = WithPendingInvitations
Expand Down
4 changes: 4 additions & 0 deletions libs/types-common/src/Data/Id.hs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ module Data.Id
ScimTokenId,
parseIdFromText,
idToText,
idToString,
idObjectSchema,
IdObject (..),

Expand Down Expand Up @@ -263,6 +264,9 @@ parseIdFromText = maybe (Left "UUID.fromText failed") (Right . Id) . UUID.fromTe
idToText :: Id a -> Text
idToText = UUID.toText . toUUID

idToString :: Id a -> String
idToString = UUID.toString . toUUID

instance Cql (Id a) where
ctype = retag (ctype :: Tagged UUID ColumnType)
toCql = toCql . toUUID
Expand Down
86 changes: 84 additions & 2 deletions libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ module Wire.API.Routes.Internal.Brig
PutAccountConferenceCallingConfig,
DeleteAccountConferenceCallingConfig,
GetRichInfoMultiResponse (..),
GetBy (..),
CreateGroupFullRequest (..),
swaggerDoc,
module Wire.API.Routes.Internal.Brig.EJPD,
FoundInvitationCode (..),
Expand All @@ -43,16 +45,18 @@ module Wire.API.Routes.Internal.Brig
where

import Control.Lens ((.~), (?~))
import Data.Aeson (FromJSON, ToJSON)
import Data.Aeson (FromJSON, ToJSON, Value (Null))
import Data.Code qualified as Code
import Data.CommaSeparatedList
import Data.Default (Default (..))
import Data.Domain (Domain)
import Data.Handle (Handle)
import Data.HavePendingInvitations (HavePendingInvitations (..))
import Data.Id as Id
import Data.Misc (PlainTextPassword8)
import Data.OpenApi (HasInfo (info), HasTitle (title), OpenApi)
import Data.OpenApi qualified as S
import Data.Qualified (Qualified)
import Data.Qualified (Qualified, qualifiedSchema)
import Data.Schema hiding (swaggerDoc)
import Data.Text qualified as Text
import GHC.TypeLits
Expand Down Expand Up @@ -91,6 +95,62 @@ import Wire.API.User.Auth.ReAuth
import Wire.API.User.Auth.Sso
import Wire.API.User.Client
import Wire.API.User.RichInfo
import Wire.API.UserGroup (NewUserGroup, UserGroup)
import Wire.Arbitrary (Arbitrary, GenericUniform (..))

-- | Parameters for getting user accounts by various criteria
data GetBy = GetBy
{ includePendingInvitations :: HavePendingInvitations,
getByUserId :: [UserId],
getByHandle :: [Handle]
}
deriving stock (Eq, Ord, Show, Generic)
deriving (Arbitrary) via GenericUniform GetBy
deriving (FromJSON, ToJSON, S.ToSchema) via Schema GetBy

instance Default GetBy where
def =
GetBy
{ includePendingInvitations = NoPendingInvitations,
getByUserId = [],
getByHandle = []
}

instance ToSchema GetBy where
schema =
object "GetBy" $
GetBy
<$> (.includePendingInvitations) .= field "include_pending_invitations" schema
<*> (.getByUserId) .= field "ids" (array schema)
<*> (.getByHandle) .= field "handles" (array schema)

instance ToSchema (Qualified GetBy) where
schema = qualifiedSchema "GetBy" "get_by" schema

deriving via (Schema (Qualified GetBy)) instance FromJSON (Qualified GetBy)

deriving via (Schema (Qualified GetBy)) instance ToJSON (Qualified GetBy)

deriving via (Schema (Qualified GetBy)) instance S.ToSchema (Qualified GetBy)

-- | Request type for creating user groups with full control
data CreateGroupFullRequest = CreateGroupFullRequest
{ managedBy :: ManagedBy,
teamId :: TeamId,
creatorUserId :: Maybe UserId,
newGroup :: NewUserGroup
}
deriving stock (Eq, Show, Generic)
deriving (FromJSON, ToJSON, S.ToSchema) via Schema CreateGroupFullRequest

instance ToSchema CreateGroupFullRequest where
schema =
object "CreateGroupFullRequest" $
CreateGroupFullRequest
<$> (.managedBy) .= field "managed_by" schema
<*> (.teamId) .= field "team_id" schema
<*> (.creatorUserId) .= optField "creator_user_id" (maybeWithDefault Null schema)
<*> (.newGroup) .= field "new_group" schema

type EJPDRequest =
Named
Expand Down Expand Up @@ -163,6 +223,26 @@ type GetAllConnections =
:> ReqBody '[Servant.JSON] ConnectionsStatusRequestV2
:> Post '[Servant.JSON] [ConnectionStatusV2]

type GetAccountsByInternal =
Named
"i-get-accounts-by"
( Summary "Get user accounts by various criteria (internal)"
:> "users"
:> "accounts-by"
:> ReqBody '[Servant.JSON] GetBy
:> Post '[Servant.JSON] [User]
)

type CreateGroupFullInternal =
Named
"i-create-group-full"
( Summary "Create user group with full control (internal)"
:> "user-groups"
:> "full"
:> ReqBody '[Servant.JSON] CreateGroupFullRequest
:> Post '[Servant.JSON] UserGroup
)

type AccountAPI =
Named "get-account-conference-calling-config" GetAccountConferenceCallingConfig
:<|> Named "i-put-account-conference-calling-config" PutAccountConferenceCallingConfig
Expand Down Expand Up @@ -469,6 +549,8 @@ type AccountAPI =
:> Capture "uid" UserId
:> Delete '[Servant.JSON] NoContent
)
:<|> GetAccountsByInternal
:<|> CreateGroupFullInternal

-- | The missing ref is implicit by the capture
data NewKeyPackageRef = NewKeyPackageRef
Expand Down
Loading