diff --git a/cabal.project b/cabal.project
index 17f642856d..e4434bfad3 100644
--- a/cabal.project
+++ b/cabal.project
@@ -60,6 +60,7 @@ packages:
, tools/stern/
, tools/mlsstats/
, tools/test-stats/
+ , tools/entreprise-provisioning/
tests: True
benchmarks: True
diff --git a/changelog.d/2-features/WPB-19716 b/changelog.d/2-features/WPB-19716
new file mode 100644
index 0000000000..aab478a44b
--- /dev/null
+++ b/changelog.d/2-features/WPB-19716
@@ -0,0 +1 @@
+Add `entreprise-provisioning`, a CLI to batch provision various entities, currently, creates and associate channels to existing user-groups.
diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs
index a81fcd5496..89825bb412 100644
--- a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs
+++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs
@@ -427,6 +427,7 @@ type UserGroupAPI =
:> "user-groups"
:> Capture "gid" UserGroupId
:> "channels"
+ :> QueryFlag "append_only"
:> ReqBody '[JSON] UpdateUserGroupChannels
:> MultiVerb1 'PUT '[JSON] (RespondEmpty 200 "User group channels updated")
)
diff --git a/libs/wire-subsystems/src/Wire/UserGroupStore.hs b/libs/wire-subsystems/src/Wire/UserGroupStore.hs
index 4839a7a89d..98a1fd7d8f 100644
--- a/libs/wire-subsystems/src/Wire/UserGroupStore.hs
+++ b/libs/wire-subsystems/src/Wire/UserGroupStore.hs
@@ -41,6 +41,7 @@ data UserGroupStore m a where
AddUser :: UserGroupId -> UserId -> UserGroupStore m ()
UpdateUsers :: UserGroupId -> Vector UserId -> UserGroupStore m ()
RemoveUser :: UserGroupId -> UserId -> UserGroupStore m ()
+ AddUserGroupChannels :: UserGroupId -> Vector ConvId -> UserGroupStore m ()
UpdateUserGroupChannels :: UserGroupId -> Vector ConvId -> UserGroupStore m ()
GetUserGroupIdsForUsers :: [UserId] -> UserGroupStore m (Map UserId [UserGroupId])
diff --git a/libs/wire-subsystems/src/Wire/UserGroupStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserGroupStore/Postgres.hs
index 685ecd26ce..58b61027ec 100644
--- a/libs/wire-subsystems/src/Wire/UserGroupStore/Postgres.hs
+++ b/libs/wire-subsystems/src/Wire/UserGroupStore/Postgres.hs
@@ -53,7 +53,8 @@ interpretUserGroupStoreToPostgres =
AddUser gid uid -> addUser gid uid
UpdateUsers gid uids -> updateUsers gid uids
RemoveUser gid uid -> removeUser gid uid
- UpdateUserGroupChannels gid convIds -> updateUserGroupChannels gid convIds
+ AddUserGroupChannels gid convIds -> updateUserGroupChannels True gid convIds
+ UpdateUserGroupChannels gid convIds -> updateUserGroupChannels False gid convIds
GetUserGroupIdsForUsers uids -> getUserGroupIdsForUsers uids
getUserGroupIdsForUsers :: (UserGroupStorePostgresEffectConstraints r) => [UserId] -> Sem r (Map UserId [UserGroupId])
@@ -413,17 +414,19 @@ removeUser =
updateUserGroupChannels ::
forall r.
(UserGroupStorePostgresEffectConstraints r) =>
+ Bool ->
UserGroupId ->
Vector ConvId ->
Sem r ()
-updateUserGroupChannels gid convIds = do
+updateUserGroupChannels appendOnly gid convIds = do
pool <- input
eitherErrorOrUnit <- liftIO $ use pool session
either throw pure eitherErrorOrUnit
where
session :: Session ()
session = TxSessions.transaction TxSessions.Serializable TxSessions.Write $ do
- Tx.statement (gid, convIds) deleteStatement
+ unless appendOnly $
+ Tx.statement (gid, convIds) deleteStatement
Tx.statement (gid, convIds) insertStatement
deleteStatement :: Statement (UserGroupId, Vector ConvId) ()
diff --git a/libs/wire-subsystems/src/Wire/UserGroupSubsystem.hs b/libs/wire-subsystems/src/Wire/UserGroupSubsystem.hs
index 5b10a64593..beb132825b 100644
--- a/libs/wire-subsystems/src/Wire/UserGroupSubsystem.hs
+++ b/libs/wire-subsystems/src/Wire/UserGroupSubsystem.hs
@@ -50,6 +50,7 @@ data UserGroupSubsystem m a where
UpdateUsers :: UserId -> UserGroupId -> Vector UserId -> UserGroupSubsystem m ()
RemoveUser :: UserId -> UserGroupId -> UserId -> UserGroupSubsystem m ()
RemoveUserFromAllGroups :: UserId -> TeamId -> UserGroupSubsystem m ()
+ AddChannels :: UserId -> UserGroupId -> Vector ConvId -> UserGroupSubsystem m ()
UpdateChannels :: UserId -> UserGroupId -> Vector ConvId -> UserGroupSubsystem m ()
makeSem ''UserGroupSubsystem
diff --git a/libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs
index 69446d9bda..fc4cfc1635 100644
--- a/libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs
+++ b/libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs
@@ -54,7 +54,8 @@ interpretUserGroupSubsystem = interpret $ \case
UpdateUsers updater groupId uids -> updateUsers updater groupId uids
RemoveUser remover groupId removeeId -> removeUser remover groupId removeeId
RemoveUserFromAllGroups uid tid -> removeUserFromAllGroups uid tid
- UpdateChannels performer groupId channelIds -> updateChannels performer groupId channelIds
+ AddChannels performer groupId channelIds -> updateChannels True performer groupId channelIds
+ UpdateChannels performer groupId channelIds -> updateChannels False performer groupId channelIds
data UserGroupSubsystemError
= UserGroupNotATeamAdmin
@@ -379,11 +380,12 @@ updateChannels ::
Member NotificationSubsystem r,
Member GalleyAPIAccess r
) =>
+ Bool ->
UserId ->
UserGroupId ->
Vector ConvId ->
Sem r ()
-updateChannels performer groupId channelIds = do
+updateChannels appendOnly performer groupId channelIds = do
void $ getUserGroup performer groupId False >>= note UserGroupNotFound
teamId <- getTeamAsAdmin performer >>= note UserGroupNotATeamAdmin
for_ channelIds $ \channelId -> do
@@ -391,7 +393,9 @@ updateChannels performer groupId channelIds = do
let meta = conv.metadata
unless (meta.cnvmTeam == Just teamId && meta.cnvmGroupConvType == Just Conversation.Channel) $
throw UserGroupChannelNotFound
- Store.updateUserGroupChannels groupId channelIds
+ if appendOnly
+ then Store.addUserGroupChannels groupId channelIds
+ else Store.updateUserGroupChannels groupId channelIds
admins <- fmap (^. TM.userId) . (^. teamMembers) <$> internalGetTeamAdmins teamId
pushNotifications
diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserGroupStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserGroupStore.hs
index 2730576ebb..3a0746384b 100644
--- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserGroupStore.hs
+++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserGroupStore.hs
@@ -68,7 +68,8 @@ userGroupStoreTestInterpreter =
AddUser gid uid -> addUserImpl gid uid
UpdateUsers gid uids -> updateUsersImpl gid uids
RemoveUser gid uid -> removeUserImpl gid uid
- UpdateUserGroupChannels gid convIds -> updateUserGroupChannelsImpl gid convIds
+ AddUserGroupChannels gid convIds -> updateUserGroupChannelsImpl True gid convIds
+ UpdateUserGroupChannels gid convIds -> updateUserGroupChannelsImpl False gid convIds
GetUserGroupIdsForUsers uids -> getUserGroupIdsForUsersImpl uids
getUserGroupIdsForUsersImpl :: (UserGroupStoreInMemEffectConstraints r) => [UserId] -> Sem r (Map UserId [UserGroupId])
@@ -205,23 +206,24 @@ removeUserImpl gid uid = do
updateUserGroupChannelsImpl ::
(UserGroupStoreInMemEffectConstraints r, Member (Input (Local ())) r) =>
+ Bool ->
UserGroupId ->
Vector ConvId ->
Sem r ()
-updateUserGroupChannelsImpl gid convIds = do
+updateUserGroupChannelsImpl appendOnly gid convIds = do
qualifyLocal <- qualifyAs <$> input
- let f :: Maybe UserGroup -> Maybe UserGroup
- f Nothing = Nothing
- f (Just g) =
- Just
- ( g
- { channels = Just $ tUntagged . qualifyLocal <$> convIds,
- channelsCount = Nothing
- } ::
- UserGroup
- )
-
- modifyUserGroupsGidOnly gid (Map.alter f)
+ let f :: UserGroup -> UserGroup
+ f g =
+ g
+ { channels =
+ Just $
+ newQualifiedConvIds <> if appendOnly then fromMaybe mempty g.channels else mempty,
+ channelsCount = Just $ length convIds
+ } ::
+ UserGroup
+ newQualifiedConvIds = tUntagged . qualifyLocal <$> convIds
+
+ modifyUserGroupsGidOnly gid (Map.alter $ fmap f)
listUserGroupChannelsImpl ::
(UserGroupStoreInMemEffectConstraints r) =>
diff --git a/nix/local-haskell-packages.nix b/nix/local-haskell-packages.nix
index 872e6dd880..00e5d82aae 100644
--- a/nix/local-haskell-packages.nix
+++ b/nix/local-haskell-packages.nix
@@ -56,6 +56,7 @@
repair-handles = hself.callPackage ../tools/db/repair-handles/default.nix { inherit gitignoreSource; };
service-backfill = hself.callPackage ../tools/db/service-backfill/default.nix { inherit gitignoreSource; };
team-info = hself.callPackage ../tools/db/team-info/default.nix { inherit gitignoreSource; };
+ entreprise-provisioning = hself.callPackage ../tools/entreprise-provisioning/default.nix { inherit gitignoreSource; };
mlsstats = hself.callPackage ../tools/mlsstats/default.nix { inherit gitignoreSource; };
rabbitmq-consumer = hself.callPackage ../tools/rabbitmq-consumer/default.nix { inherit gitignoreSource; };
rex = hself.callPackage ../tools/rex/default.nix { inherit gitignoreSource; };
diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs
index e964ee8cf6..521f2ff1d8 100644
--- a/services/brig/src/Brig/API/Public.hs
+++ b/services/brig/src/Brig/API/Public.hs
@@ -1728,8 +1728,12 @@ removeUserFromGroup lusr gid mid = lift . liftSem $ UserGroup.removeUser (tUnqua
updateUserGroupMembers :: (_) => Local UserId -> UserGroupId -> UpdateUserGroupMembers -> Handler r ()
updateUserGroupMembers lusr gid gupd = lift . liftSem $ UserGroup.updateUsers (tUnqualified lusr) gid gupd.members
-updateUserGroupChannels :: (_) => Local UserId -> UserGroupId -> UpdateUserGroupChannels -> Handler r ()
-updateUserGroupChannels lusr gid upd = lift . liftSem $ UserGroup.updateChannels (tUnqualified lusr) gid upd.channels
+updateUserGroupChannels :: (_) => Local UserId -> UserGroupId -> Bool -> UpdateUserGroupChannels -> Handler r ()
+updateUserGroupChannels lusr gid appendOnly upd =
+ lift . liftSem $
+ if appendOnly
+ then UserGroup.addChannels (tUnqualified lusr) gid upd.channels
+ else UserGroup.updateChannels (tUnqualified lusr) gid upd.channels
checkUserGroupNameAvailable :: Local UserId -> CheckUserGroupName -> Handler r UserGroupNameAvailability
checkUserGroupNameAvailable _ _ = pure $ UserGroupNameAvailability True
diff --git a/tools/entreprise-provisioning/LICENSE b/tools/entreprise-provisioning/LICENSE
new file mode 100644
index 0000000000..dba13ed2dd
--- /dev/null
+++ b/tools/entreprise-provisioning/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/tools/entreprise-provisioning/README.md b/tools/entreprise-provisioning/README.md
new file mode 100644
index 0000000000..8f16f93b0d
--- /dev/null
+++ b/tools/entreprise-provisioning/README.md
@@ -0,0 +1,268 @@
+# entreprise-provisioning
+
+A CLI tool for bulk provisioning user groups with channels in Wire.
+
+## Features
+
+- Create multiple channels for a team
+- Associate channels with user groups in bulk
+- Comprehensive error reporting with detailed JSON output
+- Environment variable support for API URL and authentication token
+- Verbose logging for debugging
+- Built-in environment variable help with `env info` command
+
+## Building
+
+```bash
+cd tools/entreprise-provisioning
+cabal build
+```
+
+## Usage
+
+```bash
+entreprise-provisioning user-groups channels \
+ -t TEAM_ID \
+ -u USER_ID \
+ --api-url API_URL \
+ --auth-token TOKEN \
+ -f INPUT_FILE \
+ [-v]
+```
+
+### Arguments
+
+- `-t, --team-id TEAM_ID`: Team ID (UUID format)
+- `-u, --user-id USER_ID`: User ID (UUID format)
+- `--api-url API_URL`: Wire API URL (e.g., `https://prod-nginz-https.wire.com` or `http://localhost:8080` for local testing)
+- `-f, --file FILENAME`: Path to input JSON file
+- `--auth-token TOKEN`: Authentication token
+- `-v, --verbose`: Enable verbose output to stderr
+
+### Environment Variables
+
+API URL and authentication token can be provided via environment variables. When set, these become the default values for their respective CLI arguments, making them optional:
+
+```bash
+export WIRE_API_URL="https://prod-nginz-https.wire.com"
+export WIRE_AUTH_TOKEN="your-bearer-token"
+```
+
+To view environment variable documentation:
+
+```bash
+entreprise-provisioning env info
+```
+
+**Configuration Priority:**
+1. Command line arguments (highest priority)
+2. Environment variables
+3. No default (needs to be specified either in env vars or on cli)
+
+## Input Format
+
+The input file should be a JSON object mapping user group IDs to arrays of channel names:
+
+```json
+{
+ "user-group-id-0": ["channel name 0", "channel name 1", "channel name 2"],
+ "user-group-id-1": ["channel name 0", "channel name 1"],
+ "user-group-id-2": ["channel name 0", "channel name 1", "channel name 2"],
+ "user-group-id-3": ["channel name 0"]
+}
+```
+
+## Output Format
+
+The tool outputs a JSON object to stdout with the following structure:
+
+```json
+{
+ "user-group-id-1": {
+ "channel": [
+ {
+ "name": "channel name 0",
+ "id": "conversation-id-uuid"
+ },
+ {
+ "name": "channel name 1",
+ "id": "conversation-id-uuid"
+ }
+ ],
+ "association": {
+ "success": true
+ }
+ },
+ "user-group-id-2": {
+ "channel": [
+ {
+ "name": "channel name 0",
+ "id": "conversation-id-uuid"
+ },
+ {
+ "name": "channel name 1",
+ "failure": {
+ "status": 401,
+ "response": {"label": "access-denied"}
+ }
+ }
+ ],
+ "association": {
+ "success": false,
+ "detail": {
+ "status": 403,
+ "response": {"label": "operation-denied"}
+ }
+ }
+ }
+}
+```
+
+### Success Cases
+
+- **Channel creation success**: Returns channel `name` and `id`
+- **Association success**: Returns `"success": true`
+
+### Error Cases
+
+- **Channel creation failure**: Returns channel `name` and `failure` object with `status` code and `response` body
+- **Association failure**: Returns `"success": false` with `detail` object containing `status` code and `response` body
+
+## Examples
+
+### View Environment Variable Help
+
+```bash
+entreprise-provisioning env info
+```
+
+Output:
+```
+Environment Variables:
+
+ WIRE_API_URL Wire API URL
+ Used as default for --api-url if set
+
+ WIRE_AUTH_TOKEN Authentication token
+ Used as default for --auth-token if set
+
+Example:
+ export WIRE_API_URL=https://prod-nginz-https.wire.com
+ export WIRE_AUTH_TOKEN=your-token-here
+```
+
+### Basic Usage (All CLI Arguments)
+
+```bash
+entreprise-provisioning user-groups channels \
+ -t "3fa85f64-5717-4562-b3fc-2c963f66afa6" \
+ -u "4fa85f64-5717-4562-b3fc-2c963f66afa7" \
+ --api-url "https://prod-nginz-https.wire.com" \
+ --auth-token "your-token-here" \
+ -f input.json
+```
+
+### With Environment Variables
+
+```bash
+export WIRE_API_URL="https://prod-nginz-https.wire.com"
+export WIRE_AUTH_TOKEN="your-token-here"
+
+# Now API URL and auth token are optional
+entreprise-provisioning user-groups channels \
+ -t "3fa85f64-5717-4562-b3fc-2c963f66afa6" \
+ -u "4fa85f64-5717-4562-b3fc-2c963f66afa7" \
+ -f input.json
+```
+
+### With Verbose Logging
+
+```bash
+entreprise-provisioning user-groups channels \
+ -t "3fa85f64-5717-4562-b3fc-2c963f66afa6" \
+ -u "4fa85f64-5717-4562-b3fc-2c963f66afa7" \
+ --api-url "https://prod-nginz-https.wire.com" \
+ --auth-token "your-token-here" \
+ -f input.json \
+ -v 2> debug.log
+```
+
+### Output to File
+
+```bash
+entreprise-provisioning user-groups channels \
+ -t "3fa85f64-5717-4562-b3fc-2c963f66afa6" \
+ -u "4fa85f64-5717-4562-b3fc-2c963f66afa7" \
+ --api-url "https://prod-nginz-https.wire.com" \
+ --auth-token "your-token-here" \
+ -f input.json \
+ > results.json
+```
+
+## Requirements
+
+- The authenticated user must be a team admin
+- The team must have channels feature enabled
+- User group IDs must exist and belong to the specified team
+- API version v12 or higher is required
+
+## Error Handling
+
+The tool continues processing even if individual operations fail:
+
+- If a channel creation fails, it's recorded in the output and processing continues with the next channel
+- If some channels succeed and others fail, the tool will attempt to associate the successful ones
+- If association fails, the error is recorded in the output
+
+## Troubleshooting
+
+### Authentication Errors
+
+- **Error**: `No authentication token provided`
+ - **Solution**: Provide token via `--auth-token` argument or `WIRE_AUTH_TOKEN` environment variable
+
+- **Error**: `401 Unauthorized`
+ - **Solution**: Verify your authentication token is valid and not expired
+
+### Configuration Errors
+
+- **Error**: Missing `--api-url` argument
+ - **Solution**: Provide via `--api-url` argument or set `WIRE_API_URL` environment variable
+
+- **Error**: Missing `--auth-token` argument
+ - **Solution**: Provide via `--auth-token` argument or set `WIRE_AUTH_TOKEN` environment variable
+
+### Permission Errors
+
+- **Error**: `403 Forbidden` on channel creation
+ - **Solution**: Ensure the user is a team admin and has permission to create channels
+
+- **Error**: `403 Forbidden` on association
+ - **Solution**: Verify the user has permission to manage user groups
+
+### API Errors
+
+- **Error**: `404 Not Found` on user group
+ - **Solution**: Verify the user group ID exists and belongs to the team
+
+- **Error**: `channels-not-enabled`
+ - **Solution**: Enable the channels feature for the team
+
+## Testing
+
+### Automated End-to-End Test
+
+An automated end-to-end test script is provided to verify the complete workflow:
+
+```bash
+./test-e2e.sh
+```
+
+This script:
+1. Creates a team and admin user
+2. Enables the channels feature
+3. Creates test user groups
+4. Runs the CLI tool
+5. Verifies the results
+
+For detailed testing documentation, see [TEST.md](TEST.md).
diff --git a/tools/entreprise-provisioning/TEST.md b/tools/entreprise-provisioning/TEST.md
new file mode 100644
index 0000000000..54626e28c5
--- /dev/null
+++ b/tools/entreprise-provisioning/TEST.md
@@ -0,0 +1,326 @@
+# End-to-End Testing
+
+This directory contains an end-to-end test script (`test-e2e.sh`) that validates the complete workflow of the `entreprise-provisioning` CLI tool.
+
+## Test Script Overview
+
+The `test-e2e.sh` script performs a complete end-to-end test:
+
+1. **Creates a team admin user** via Brig's internal API (`/i/users`)
+2. **Logs in** to obtain an access token (`POST /login`)
+3. **Enables the channels feature** for the team via Galley's internal API (`PATCH /i/teams/{tid}/features/channels`)
+4. **Creates user groups** via Brig's API (`POST /v12/user-groups`)
+5. **Runs the CLI tool** with a generated input file
+6. **Verifies the results** by parsing the JSON output
+
+## Prerequisites
+
+### 1. Infrastructure Services (Docker)
+
+Start the infrastructure services (databases, queues, etc.):
+
+```bash
+cd deploy/dockerephemeral
+./run.sh
+```
+
+This starts:
+- DynamoDB (port 4567)
+- SQS (port 4568)
+- S3/MinIO (port 4570)
+- Cassandra (port 9042)
+- Elasticsearch (port 9200)
+- PostgreSQL (port 5432)
+- RabbitMQ (ports 5671, 15671, 15672)
+- Redis (port 6379)
+- And more...
+
+### 2. Wire Server Services
+
+The Wire server services (brig, galley, etc.) need to be running on their default ports:
+
+- **Brig**: localhost:8082
+- **Galley**: localhost:8085
+- **Cannon**: localhost:8083
+- **Cargohold**: localhost:8084
+- **Gundeck**: localhost:8086
+- **Spar**: localhost:8088
+- **Nginz**: localhost:8080
+
+You can start these services using one of the following methods:
+
+#### Option A: Using integration test setup
+
+```bash
+# From the wire-server root directory
+make services
+```
+
+#### Option B: Using cabal directly
+
+Start each service in a separate terminal:
+
+```bash
+# Terminal 1: Brig
+cabal run brig -- -c services/brig/brig.integration.yaml
+
+# Terminal 2: Galley
+cabal run galley -- -c services/galley/galley.integration.yaml
+
+# Terminal 3: Cannon (optional for this test)
+cabal run cannon -- -c services/cannon/cannon.integration.yaml
+
+# etc...
+```
+
+#### Option C: Using the integration test runner
+
+The integration test infrastructure automatically starts services:
+
+```bash
+# This will start all services needed for integration tests
+make integration
+```
+
+### 3. Build the CLI Tool
+
+```bash
+cd tools/entreprise-provisioning
+cabal build
+```
+
+## Running the Test
+
+Once all prerequisites are met:
+
+```bash
+cd tools/entreprise-provisioning
+./test-e2e.sh
+```
+
+### Verbose Output
+
+Enable verbose output to see detailed progress:
+
+```bash
+VERBOSE=1 ./test-e2e.sh
+```
+
+### Custom Service URLs
+
+Override the default service URLs:
+
+```bash
+BRIG_INTERNAL=http://localhost:8082 \
+GALLEY_INTERNAL=http://localhost:8085 \
+./test-e2e.sh
+```
+
+## Expected Output
+
+When successful, you should see:
+
+```
+[INFO] Starting end-to-end test for entreprise-provisioning
+
+[INFO] === Step 1: Creating team admin ===
+[INFO] Creating team admin user: abc12345@example.com
+[INFO] Created user: on team:
+
+[INFO] === Step 2: Logging in ===
+[INFO] Logging in as abc12345@example.com
+[INFO] Logged in successfully
+
+[INFO] === Step 3: Enabling channels feature ===
+[INFO] Enabling channels feature for team
+[INFO] Channels feature enabled
+
+[INFO] === Step 4: Creating user groups ===
+[INFO] Creating user group: Engineering
+[INFO] Created user group:
+[INFO] Creating user group: Marketing
+[INFO] Created user group:
+[INFO] Creating user group: Sales
+[INFO] Created user group:
+
+[INFO] === Step 5: Creating input JSON ===
+[INFO] Input file: /tmp/tmp.XXXXXXXXXX
+
+[INFO] === Step 6: Running entreprise-provisioning CLI ===
+[INFO] Using CLI binary:
+[INFO] CLI tool completed successfully
+
+[INFO] === Step 7: Verifying results ===
+[INFO] Output is valid JSON
+[INFO] All 3 groups present in results
+[INFO] Total channels: 8
+[INFO] Successful channels: 8
+[INFO] Failed channels: 0
+[INFO] Successful associations: 3
+
+[INFO] === Test Summary ===
+[INFO] Team ID:
+[INFO] User ID:
+[INFO] User Groups Created: 3
+[INFO] - Engineering (): 3 channels
+[INFO] - Marketing (): 2 channels
+[INFO] - Sales (): 3 channels
+[INFO] Total Channels Created: 8/8
+[INFO] Successful Associations: 3/3
+
+[INFO] ✓ All tests passed!
+```
+
+## Test Scenario
+
+The test creates the following scenario:
+
+### User Groups and Channels
+
+1. **Engineering** group with 3 channels:
+ - general
+ - backend
+ - frontend
+
+2. **Marketing** group with 2 channels:
+ - general
+ - campaigns
+
+3. **Sales** group with 3 channels:
+ - general
+ - deals
+ - customers
+
+Total: 3 user groups, 8 channels
+
+## Troubleshooting
+
+### Services Not Running
+
+If you see:
+```
+[ERROR] Failed to create team admin (HTTP XXX)
+```
+
+Check that the infrastructure and Wire services are running:
+
+```bash
+# Check Brig
+curl http://localhost:8082/i/status
+
+# Check Galley
+curl http://localhost:8085/i/status
+```
+
+### CLI Binary Not Found
+
+If you see:
+```
+[ERROR] Cannot find entreprise-provisioning binary
+```
+
+Build the CLI tool:
+
+```bash
+cd tools/entreprise-provisioning
+cabal build
+```
+
+### Feature Not Available
+
+If channels feature cannot be enabled, it might not be available in your Wire server version. Check that you're running a version that supports channels.
+
+### JSON Parsing Errors
+
+If the output contains parsing errors, ensure you have `jq` installed:
+
+```bash
+# Ubuntu/Debian
+sudo apt-get install jq
+
+# macOS
+brew install jq
+
+# Arch Linux
+sudo pacman -S jq
+```
+
+## Manual Testing
+
+You can also test the CLI manually after creating a team and user groups:
+
+1. Create a team admin using the helper script:
+
+```bash
+../../hack/bin/create_test_team_admins.sh -n 1
+```
+
+This outputs: `User-Id,Email,Password`
+
+2. Login to get a token (you'll need to implement a login script or use the Wire client)
+
+3. Enable channels via API:
+
+```bash
+curl -X PATCH "http://localhost:8085/i/teams/$TEAM_ID/features/channels" \
+ -H "Content-Type: application/json" \
+ -d '{"status": "enabled"}'
+```
+
+4. Create user groups via API:
+
+```bash
+curl -X POST "http://localhost:8082/v12/user-groups" \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer $TOKEN" \
+ -d '{"name": "Engineering", "members": []}'
+```
+
+5. Create an input JSON file:
+
+```json
+{
+ "group-uuid-1": ["general", "backend", "frontend"],
+ "group-uuid-2": ["general", "campaigns"]
+}
+```
+
+6. Run the CLI:
+
+```bash
+cabal run entreprise-provisioning -- user-groups channels \
+ -t "$TEAM_ID" \
+ --galley-url "http://localhost:8085" \
+ --brig-url "http://localhost:8082" \
+ --auth-token "$TOKEN" \
+ -f input.json \
+ -v
+```
+
+## Integration with CI/CD
+
+To integrate this test into your CI/CD pipeline:
+
+```bash
+# Start infrastructure
+cd deploy/dockerephemeral && ./run.sh &
+sleep 30 # Wait for services to be ready
+
+# Start Wire services (implementation depends on your setup)
+make services &
+sleep 10
+
+# Run the test
+cd tools/entreprise-provisioning
+./test-e2e.sh
+
+# Cleanup
+pkill -f wire-server
+docker-compose -f deploy/dockerephemeral/docker-compose.yaml down
+```
+
+## See Also
+
+- [README.md](README.md) - Main CLI tool documentation
+- [integration/test/Test/UserGroup.hs](../../integration/test/Test/UserGroup.hs) - Integration tests for user groups
+- [services/integration.yaml](../../services/integration.yaml) - Service port configuration
diff --git a/tools/entreprise-provisioning/default.nix b/tools/entreprise-provisioning/default.nix
new file mode 100644
index 0000000000..b2e1cd807a
--- /dev/null
+++ b/tools/entreprise-provisioning/default.nix
@@ -0,0 +1,66 @@
+# WARNING: GENERATED FILE, DO NOT EDIT.
+# This file is generated by running hack/bin/generate-local-nix-packages.sh and
+# must be regenerated whenever local packages are added or removed, or
+# dependencies are added or removed.
+{ mkDerivation
+, aeson
+, aeson-pretty
+, base
+, bytestring
+, containers
+, envparse
+, gitignoreSource
+, http-client
+, http-client-tls
+, http-types
+, imports
+, lib
+, optparse-applicative
+, tasty
+, tasty-golden
+, tasty-hunit
+, text
+, types-common
+, uuid
+, vector
+, wire-api
+}:
+mkDerivation {
+ pname = "entreprise-provisioning";
+ version = "0.1.0";
+ src = gitignoreSource ./.;
+ isLibrary = false;
+ isExecutable = true;
+ executableHaskellDepends = [
+ aeson
+ base
+ bytestring
+ containers
+ envparse
+ http-client
+ http-client-tls
+ http-types
+ imports
+ optparse-applicative
+ text
+ types-common
+ uuid
+ vector
+ wire-api
+ ];
+ testHaskellDepends = [
+ aeson
+ aeson-pretty
+ base
+ bytestring
+ containers
+ imports
+ tasty
+ tasty-golden
+ tasty-hunit
+ types-common
+ ];
+ description = "CLI tool for provisioning user groups with channels";
+ license = lib.licenses.agpl3Only;
+ mainProgram = "entreprise-provisioning";
+}
diff --git a/tools/entreprise-provisioning/entreprise-provisioning.cabal b/tools/entreprise-provisioning/entreprise-provisioning.cabal
new file mode 100644
index 0000000000..fa89078a1b
--- /dev/null
+++ b/tools/entreprise-provisioning/entreprise-provisioning.cabal
@@ -0,0 +1,159 @@
+cabal-version: >=1.10
+name: entreprise-provisioning
+version: 0.1.0
+synopsis: CLI tool for provisioning user groups with channels
+description: Create channels and associate them with user groups in bulk
+category: Network
+author: Wire Swiss GmbH
+maintainer: Wire Swiss GmbH
+license: AGPL-3
+license-file: LICENSE
+build-type: Simple
+
+flag static
+ description: Enable static linking
+ default: False
+
+executable entreprise-provisioning
+ main-is: Main.hs
+ other-modules:
+ API
+ CLI
+ Types
+
+ hs-source-dirs: src
+ build-depends:
+ aeson
+ , base >=4 && <5
+ , bytestring
+ , containers
+ , envparse
+ , http-client
+ , http-client-tls
+ , http-types
+ , imports
+ , optparse-applicative
+ , text
+ , types-common
+ , uuid
+ , vector
+ , wire-api
+
+ ghc-options:
+ -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates
+ -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path
+ -funbox-strict-fields -threaded "-with-rtsopts=-N -T" -rtsopts
+ -Wredundant-constraints
+
+ if flag(static)
+ ld-options: -static
+
+ default-language: GHC2021
+ default-extensions:
+ AllowAmbiguousTypes
+ BangPatterns
+ ConstraintKinds
+ DataKinds
+ DefaultSignatures
+ DeriveFunctor
+ DeriveGeneric
+ DeriveLift
+ DeriveTraversable
+ DerivingStrategies
+ DerivingVia
+ DuplicateRecordFields
+ DuplicateRecordFields
+ EmptyCase
+ FlexibleContexts
+ FlexibleInstances
+ FunctionalDependencies
+ GADTs
+ InstanceSigs
+ KindSignatures
+ LambdaCase
+ MultiParamTypeClasses
+ MultiWayIf
+ NamedFieldPuns
+ NoImplicitPrelude
+ OverloadedRecordDot
+ OverloadedStrings
+ PackageImports
+ PatternSynonyms
+ PolyKinds
+ QuasiQuotes
+ RankNTypes
+ ScopedTypeVariables
+ StandaloneDeriving
+ TupleSections
+ TypeApplications
+ TypeFamilies
+ TypeFamilyDependencies
+ TypeOperators
+ UndecidableInstances
+ ViewPatterns
+
+test-suite spec
+ type: exitcode-stdio-1.0
+ main-is: Main.hs
+ other-modules: Types
+ hs-source-dirs: test src
+ build-depends:
+ aeson
+ , aeson-pretty
+ , base >=4 && <5
+ , bytestring
+ , containers
+ , imports
+ , tasty
+ , tasty-golden
+ , tasty-hunit
+ , types-common
+
+ ghc-options:
+ -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates
+ -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path
+ -funbox-strict-fields -threaded "-with-rtsopts=-N -T" -rtsopts
+ -Wredundant-constraints
+
+ default-language: GHC2021
+ default-extensions:
+ AllowAmbiguousTypes
+ BangPatterns
+ ConstraintKinds
+ DataKinds
+ DefaultSignatures
+ DeriveFunctor
+ DeriveGeneric
+ DeriveLift
+ DeriveTraversable
+ DerivingStrategies
+ DerivingVia
+ DuplicateRecordFields
+ EmptyCase
+ FlexibleContexts
+ FlexibleInstances
+ FunctionalDependencies
+ GADTs
+ InstanceSigs
+ KindSignatures
+ LambdaCase
+ MultiParamTypeClasses
+ MultiWayIf
+ NamedFieldPuns
+ NoImplicitPrelude
+ OverloadedRecordDot
+ OverloadedStrings
+ PackageImports
+ PatternSynonyms
+ PolyKinds
+ QuasiQuotes
+ RankNTypes
+ ScopedTypeVariables
+ StandaloneDeriving
+ TupleSections
+ TypeApplications
+ TypeFamilies
+ TypeFamilyDependencies
+ TypeOperators
+ UndecidableInstances
+ ViewPatterns
diff --git a/tools/entreprise-provisioning/examples/input-example.json b/tools/entreprise-provisioning/examples/input-example.json
new file mode 100644
index 0000000000..b7e776b30d
--- /dev/null
+++ b/tools/entreprise-provisioning/examples/input-example.json
@@ -0,0 +1,6 @@
+{
+ "user-group-id-0": ["channel name 0", "channel name 1", "channel name 2", "channel name 3"],
+ "user-group-id-1": ["channel name 0", "channel name 1"],
+ "user-group-id-2": ["channel name 0", "channel name 1", "channel name 2"],
+ "user-group-id-3": ["channel name 0"]
+}
diff --git a/tools/entreprise-provisioning/examples/output-example.json b/tools/entreprise-provisioning/examples/output-example.json
new file mode 100644
index 0000000000..d6d234e649
--- /dev/null
+++ b/tools/entreprise-provisioning/examples/output-example.json
@@ -0,0 +1,60 @@
+{
+ "user-group-id-1": {
+ "channel": [
+ {
+ "name": "channel name 0",
+ "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
+ },
+ {
+ "name": "channel name 1",
+ "id": "4fa85f64-5717-4562-b3fc-2c963f66afa6"
+ }
+ ],
+ "association": {
+ "success": true
+ }
+ },
+ "user-group-id-2": {
+ "channel": [
+ {
+ "name": "channel name 0",
+ "id": "5fa85f64-5717-4562-b3fc-2c963f66afa6"
+ },
+ {
+ "name": "channel name 1",
+ "id": "6fa85f64-5717-4562-b3fc-2c963f66afa6"
+ },
+ {
+ "name": "channel name 2",
+ "failure": {
+ "status": 401,
+ "response": {
+ "label": "access-denied",
+ "message": "Insufficient permissions to create channel"
+ }
+ }
+ }
+ ],
+ "association": {
+ "success": true
+ }
+ },
+ "user-group-id-3": {
+ "channel": [
+ {
+ "name": "channel name 0",
+ "id": "7fa85f64-5717-4562-b3fc-2c963f66afa6"
+ }
+ ],
+ "association": {
+ "success": false,
+ "detail": {
+ "status": 403,
+ "response": {
+ "label": "operation-denied",
+ "message": "User not authorized to modify user group"
+ }
+ }
+ }
+ }
+}
diff --git a/tools/entreprise-provisioning/src/API.hs b/tools/entreprise-provisioning/src/API.hs
new file mode 100644
index 0000000000..90bca4861b
--- /dev/null
+++ b/tools/entreprise-provisioning/src/API.hs
@@ -0,0 +1,147 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2025 Wire Swiss GmbH
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module API
+ ( createChannel,
+ associateChannelsToGroup,
+ )
+where
+
+import Control.Exception (try)
+import Data.Aeson
+import Data.Aeson.Types (parseMaybe)
+import Data.ByteString.Lazy qualified as LBS
+import Data.Id
+import Data.Range
+import Data.Set qualified as Set
+import Data.Text qualified as Text
+import Data.Text.Encoding qualified as Text
+import Data.Vector qualified as V
+import Imports
+import Network.HTTP.Client
+import Network.HTTP.Types.Status
+import Types
+import Wire.API.Conversation
+import Wire.API.Conversation.Role (roleNameWireAdmin)
+import Wire.API.User (BaseProtocolTag (..))
+import Wire.API.UserGroup (UpdateUserGroupChannels (..))
+
+createChannel ::
+ Manager ->
+ ApiUrl ->
+ Token ->
+ UserId ->
+ TeamId ->
+ ChannelName ->
+ IO (Either ErrorDetail ConvId)
+createChannel manager (ApiUrl apiUrl) (Token token) userId teamId channelName = do
+ let url = apiUrl <> "/v12/conversations"
+ newConv =
+ NewConv
+ { newConvUsers = [],
+ newConvQualifiedUsers = [],
+ newConvName = Just (unsafeRange (fromRange (fromChannelName channelName))),
+ newConvAccess = Set.empty,
+ newConvAccessRoles = Just (Set.singleton TeamMemberAccessRole),
+ newConvTeam = Just (ConvTeamInfo teamId),
+ newConvMessageTimer = Nothing,
+ newConvReceiptMode = Nothing,
+ newConvUsersRole = roleNameWireAdmin,
+ newConvProtocol = BaseProtocolMLSTag,
+ newConvGroupConvType = Channel,
+ newConvCells = False,
+ newConvChannelAddPermission = Nothing,
+ newConvSkipCreator = False,
+ newConvParent = Nothing
+ }
+ body = encode newConv
+
+ initialRequest <- parseRequest url
+ let request =
+ initialRequest
+ { method = "POST",
+ requestHeaders =
+ [ ("Authorization", "Bearer " <> Text.encodeUtf8 token),
+ ("Content-Type", "application/json"),
+ ("Z-User", Text.encodeUtf8 . Text.pack . show $ userId)
+ ],
+ requestBody = RequestBodyLBS body
+ }
+
+ result <- try $ httpLbs request manager
+ case result of
+ Left (e :: HttpException) ->
+ pure $ Left $ ErrorDetail 0 (object ["error" .= show e])
+ Right resp ->
+ let respStatus = statusCode (responseStatus resp)
+ in case respStatus of
+ 201 -> do
+ case eitherDecode (responseBody resp) of
+ Right (Object obj) ->
+ case parseMaybe (\o -> o .: "qualified_id" >>= (.: "id")) obj of
+ Just convId -> pure $ Right convId
+ Nothing ->
+ case parseMaybe (.: "id") obj of
+ Just convId -> pure $ Right convId
+ Nothing -> pure $ Left $ ErrorDetail respStatus (object ["error" .= ("Failed to extract conversation ID" :: Text)])
+ Right _ -> pure $ Left $ ErrorDetail respStatus (object ["error" .= ("Invalid response format" :: Text)])
+ Left err -> pure $ Left $ ErrorDetail respStatus (object ["error" .= Text.pack err])
+ code ->
+ pure $ Left $ ErrorDetail code (decodeResponse (responseBody resp))
+
+associateChannelsToGroup ::
+ Manager ->
+ ApiUrl ->
+ Token ->
+ UserId ->
+ UserGroupId ->
+ [ConvId] ->
+ IO (Either ErrorDetail ())
+associateChannelsToGroup manager (ApiUrl apiUrl) (Token token) userId groupId convIds = do
+ let url = apiUrl <> "/v12/user-groups/" <> show groupId <> "/channels?append_only=true"
+ body = UpdateUserGroupChannels {channels = V.fromList convIds}
+
+ initialRequest <- parseRequest url
+ let request =
+ initialRequest
+ { method = "PUT",
+ requestHeaders =
+ [ ("Authorization", "Bearer " <> Text.encodeUtf8 token),
+ ("Content-Type", "application/json"),
+ ("Z-User", Text.encodeUtf8 . Text.pack . show $ userId)
+ ],
+ requestBody = RequestBodyLBS (encode body)
+ }
+
+ result <- try $ httpLbs request manager
+ case result of
+ Left (e :: HttpException) ->
+ pure $ Left $ ErrorDetail 0 (object ["error" .= show e])
+ Right resp ->
+ case statusCode (responseStatus resp) of
+ 200 -> pure $ Right ()
+ code ->
+ pure $ Left $ ErrorDetail code (decodeResponse (responseBody resp))
+
+decodeResponse :: LBS.ByteString -> Value
+decodeResponse body =
+ case decode body of
+ Just v -> v
+ Nothing ->
+ object
+ [ "raw" .= Text.decodeUtf8 (LBS.toStrict body)
+ ]
diff --git a/tools/entreprise-provisioning/src/CLI.hs b/tools/entreprise-provisioning/src/CLI.hs
new file mode 100644
index 0000000000..eb8dee6bcd
--- /dev/null
+++ b/tools/entreprise-provisioning/src/CLI.hs
@@ -0,0 +1,150 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2025 Wire Swiss GmbH
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module CLI
+ ( Command (..),
+ ProvisionUserGroupChannelsOptions (..),
+ commandParser,
+ envParser,
+ )
+where
+
+import Data.Id
+import Data.UUID qualified as UUID
+import Env qualified
+import Imports
+import Options.Applicative
+import Types
+
+data Env = Env
+ { envApiUrl :: Maybe ApiUrl,
+ envAuthToken :: Maybe Token
+ }
+
+data ProvisionUserGroupChannelsOptions = ProvisionUserGroupChannelsOptions
+ { teamId :: TeamId,
+ userId :: UserId,
+ apiUrl :: ApiUrl,
+ inputFile :: FilePath,
+ authToken :: Token,
+ verbose :: Bool
+ }
+
+data Command
+ = ProvisionUserGroupChannels ProvisionUserGroupChannelsOptions
+ | EnvInfo
+
+envParser :: Env.Parser Env.Error Env
+envParser =
+ Env
+ <$> Env.optional (ApiUrl <$> Env.var Env.str "WIRE_API_URL" (Env.help "Wire API URL"))
+ <*> Env.optional (Token <$> Env.var Env.str "WIRE_AUTH_TOKEN" (Env.help "Authentication token"))
+
+commandParser :: Env -> Parser Command
+commandParser env =
+ hsubparser $
+ mconcat
+ [ command
+ "user-groups"
+ ( info
+ (userGroupsParser env)
+ (progDesc "Provision user groups with channels")
+ ),
+ command
+ "env"
+ ( info
+ envInfoParser
+ (progDesc "Environment variable information")
+ )
+ ]
+
+userGroupsParser :: Env -> Parser Command
+userGroupsParser env =
+ hsubparser $
+ command
+ "channels"
+ ( info
+ (channelsParser env)
+ (progDesc "Create channels and associate them with user groups")
+ )
+ where
+ channelsParser :: Env -> Parser Command
+ channelsParser e =
+ ProvisionUserGroupChannels <$> provisionParser e
+
+ provisionParser :: Env -> Parser ProvisionUserGroupChannelsOptions
+ provisionParser e =
+ ProvisionUserGroupChannelsOptions
+ <$> option
+ (maybeReader $ fmap Id . UUID.fromString)
+ ( short 't'
+ <> long "team-id"
+ <> metavar "TEAM_ID"
+ <> help "Team ID (UUID)"
+ )
+ <*> option
+ (maybeReader $ fmap Id . UUID.fromString)
+ ( short 'u'
+ <> long "user-id"
+ <> metavar "USER_ID"
+ <> help "User ID (UUID)"
+ )
+ <*> ( ApiUrl
+ <$> strOption
+ ( long "api-url"
+ <> metavar "API_URL"
+ <> help "Wire API URL"
+ <> foldMap (\url -> value url.fromApiUrl <> showDefault) e.envApiUrl
+ )
+ )
+ <*> strOption
+ ( short 'f'
+ <> long "file"
+ <> metavar "FILENAME"
+ <> help "Input JSON file"
+ )
+ <*> case envAuthToken e of
+ Just token ->
+ option
+ (Token <$> str)
+ ( long "auth-token"
+ <> metavar "TOKEN"
+ <> help "Authentication token"
+ <> value token
+ )
+ Nothing ->
+ Token
+ <$> strOption
+ ( long "auth-token"
+ <> metavar "TOKEN"
+ <> help "Authentication token"
+ )
+ <*> switch
+ ( short 'v'
+ <> long "verbose"
+ <> help "Enable verbose output to stderr"
+ )
+
+envInfoParser :: Parser Command
+envInfoParser =
+ hsubparser $
+ command
+ "info"
+ ( info
+ (pure EnvInfo)
+ (progDesc "Display environment variable parsing help")
+ )
diff --git a/tools/entreprise-provisioning/src/Main.hs b/tools/entreprise-provisioning/src/Main.hs
new file mode 100644
index 0000000000..9ec143ec1e
--- /dev/null
+++ b/tools/entreprise-provisioning/src/Main.hs
@@ -0,0 +1,144 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2025 Wire Swiss GmbH
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Main (main) where
+
+import API
+import CLI
+import Data.Aeson
+import Data.ByteString.Lazy qualified as LBS
+import Data.Id
+import Data.Map.Strict qualified as Map
+import Data.Range
+import Data.Text qualified as Text
+import Env qualified
+import Env.Internal.Help qualified as Env
+import Imports
+import Network.HTTP.Client
+import Network.HTTP.Client.TLS
+import Options.Applicative
+import System.Exit (die)
+import System.IO (hPutStrLn)
+import Types
+
+main :: IO ()
+main = do
+ env <- Env.parse (Env.header "entreprise-provisioning") envParser
+ cmd <- customExecParser (prefs showHelpOnEmpty) (info (commandParser env <**> helper) fullDesc)
+
+ case cmd of
+ EnvInfo -> do
+ putStrLn $ Env.helpInfo (Env.header "entreprise-provisioning" Env.defaultInfo) envParser mempty
+ ProvisionUserGroupChannels opts -> do
+ when opts.verbose $ do
+ hPutStrLn stderr $ "API URL: " <> show opts.apiUrl
+ hPutStrLn stderr $ "Loading input from: " <> inputFile opts
+
+ inputData <- LBS.readFile opts.inputFile
+ UserGroupChannelsProvisionningSpec input <- case decode inputData of
+ Nothing -> die "Failed to parse input JSON"
+ Just x -> pure x
+
+ when opts.verbose $
+ hPutStrLn stderr $
+ "Processing " <> show (Map.size input) <> " user groups"
+
+ manager <- newManager tlsManagerSettings
+
+ userGroupResults <- Map.traverseWithKey (processUserGroup manager opts opts.authToken) input
+
+ LBS.putStr $ encode $ UserGroupChannelsProvisionningResult userGroupResults
+
+-- | Process a single user group
+processUserGroup ::
+ Manager ->
+ ProvisionUserGroupChannelsOptions ->
+ Token ->
+ UserGroupId ->
+ [ChannelName] ->
+ IO UserGroupResult
+processUserGroup manager opts token groupId channelNames = do
+ when opts.verbose $
+ hPutStrLn stderr $
+ "Processing user group: "
+ <> show groupId
+ <> " with "
+ <> show (length channelNames)
+ <> " channels"
+
+ -- Create channels
+ channelResults <- mapM (createChannelWithLogging manager opts token) channelNames
+
+ -- Extract successful channel IDs
+ let successfulIds = [cid | ChannelSuccess _ cid <- channelResults]
+
+ when opts.verbose $
+ hPutStrLn stderr $
+ "Successfully created "
+ <> show (length successfulIds)
+ <> " out of "
+ <> show (length channelNames)
+ <> " channels"
+
+ -- Associate channels to group
+ assocResult <-
+ if null successfulIds
+ then do
+ when opts.verbose $
+ hPutStrLn stderr "Skipping association: no channels created successfully"
+ pure $
+ Left $
+ ErrorDetail 0 $
+ object ["error" .= ("no channels created" :: Text)]
+ else do
+ when opts.verbose $
+ hPutStrLn stderr "Associating channels to user group..."
+ associateChannelsToGroup manager opts.apiUrl token opts.userId groupId successfulIds
+
+ pure $
+ UserGroupResult
+ { channel = channelResults,
+ association = case assocResult of
+ Right _ -> AssociationSuccess
+ Left err -> AssociationFailureResult err
+ }
+
+-- | Create a channel with logging
+createChannelWithLogging ::
+ Manager ->
+ ProvisionUserGroupChannelsOptions ->
+ Token ->
+ ChannelName ->
+ IO ChannelResult
+createChannelWithLogging manager opts token channelName' = do
+ when opts.verbose $
+ hPutStrLn stderr $
+ "Creating channel: " <> Text.unpack (fromRange $ fromChannelName channelName')
+
+ result <- createChannel manager opts.apiUrl token opts.userId opts.teamId channelName'
+
+ case result of
+ Right convId -> do
+ when opts.verbose $
+ hPutStrLn stderr $
+ "✓ Channel created: " <> Text.unpack (fromRange $ fromChannelName channelName')
+ pure $ ChannelSuccess channelName' convId
+ Left err -> do
+ when opts.verbose $
+ hPutStrLn stderr $
+ "✗ Channel creation failed: " <> Text.unpack (fromRange $ fromChannelName channelName')
+ pure $ ChannelFailure channelName' err
diff --git a/tools/entreprise-provisioning/src/Types.hs b/tools/entreprise-provisioning/src/Types.hs
new file mode 100644
index 0000000000..cca678e3bb
--- /dev/null
+++ b/tools/entreprise-provisioning/src/Types.hs
@@ -0,0 +1,113 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2025 Wire Swiss GmbH
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Types where
+
+import Data.Aeson
+import Data.Id
+import Data.Range
+import GHC.Generics
+import Imports
+
+newtype Token = Token {fromToken :: Text}
+ deriving stock (Eq, Show, Generic)
+
+newtype ApiUrl = ApiUrl {fromApiUrl :: String}
+ deriving stock (Eq, Generic)
+ deriving newtype (Show)
+
+newtype ChannelName = ChannelName {fromChannelName :: Range 1 256 Text}
+ deriving stock (Eq, Show, Generic)
+ deriving newtype (ToJSON, FromJSON)
+
+newtype UserGroupChannelsProvisionningSpec = UserGroupChannelsProvisionningSpec
+ { byGroup :: Map UserGroupId [ChannelName]
+ }
+ deriving stock (Eq, Show, Generic)
+ deriving newtype (ToJSON, FromJSON)
+
+newtype UserGroupChannelsProvisionningResult = UserGroupChannelsProvisionningResult
+ { results :: Map UserGroupId UserGroupResult
+ }
+ deriving stock (Eq, Show, Generic)
+ deriving newtype (FromJSON, ToJSON)
+
+data UserGroupResult = UserGroupResult
+ { channel :: [ChannelResult],
+ association :: AssociationResult
+ }
+ deriving stock (Eq, Show, Generic)
+ deriving (FromJSON, ToJSON) via (Generically UserGroupResult)
+
+data ChannelResult
+ = ChannelSuccess ChannelName ConvId
+ | ChannelFailure ChannelName ErrorDetail
+ deriving stock (Eq, Show, Generic)
+
+channelResultName :: ChannelResult -> ChannelName
+channelResultName (ChannelSuccess n _) = n
+channelResultName (ChannelFailure n _) = n
+
+instance ToJSON ChannelResult where
+ toJSON (ChannelSuccess n cid) =
+ object
+ [ "name" .= n,
+ "id" .= cid
+ ]
+ toJSON (ChannelFailure n f) =
+ object
+ [ "name" .= n,
+ "failure" .= f
+ ]
+
+instance FromJSON ChannelResult where
+ parseJSON = withObject "ChannelResult" $ \o -> do
+ name <- o .: "name"
+ mId <- o .:? "id"
+ mFailure <- o .:? "failure"
+ case (mId, mFailure) of
+ (Just cid, Nothing) -> pure $ ChannelSuccess name cid
+ (Nothing, Just f) -> pure $ ChannelFailure name f
+ _ -> fail "ChannelResult must have either 'id' or 'failure' field"
+
+data AssociationResult
+ = AssociationSuccess
+ | AssociationFailureResult ErrorDetail
+ deriving stock (Eq, Show, Generic)
+
+instance ToJSON AssociationResult where
+ toJSON AssociationSuccess =
+ object ["success" .= True]
+ toJSON (AssociationFailureResult det) =
+ object
+ [ "success" .= False,
+ "detail" .= det
+ ]
+
+instance FromJSON AssociationResult where
+ parseJSON = withObject "AssociationResult" $ \o -> do
+ success <- o .: "success"
+ if success
+ then pure AssociationSuccess
+ else AssociationFailureResult <$> o .: "detail"
+
+data ErrorDetail = ErrorDetail
+ { status :: Int,
+ response :: Value
+ }
+ deriving stock (Eq, Show, Generic)
+ deriving (FromJSON, ToJSON) via (Generically ErrorDetail)
diff --git a/tools/entreprise-provisioning/test-e2e.sh b/tools/entreprise-provisioning/test-e2e.sh
new file mode 100755
index 0000000000..44d729de91
--- /dev/null
+++ b/tools/entreprise-provisioning/test-e2e.sh
@@ -0,0 +1,446 @@
+#!/usr/bin/env bash
+#
+# End-to-end test script for entreprise-provisioning CLI tool
+#
+# This script:
+# 1. Creates a team admin user
+# 2. Logs in to get an auth token
+# 3. Enables channels feature for the team
+# 4. Creates user groups
+# 5. Runs the CLI tool to create channels and associate them with user groups
+# 6. Verifies the results
+#
+# Prerequisites:
+# - Wire server services running (dockerephemeral + local services)
+# - Services accessible at localhost on their default ports
+# - entreprise-provisioning CLI tool built and in PATH or current directory
+
+set -e
+
+# Configuration
+BRIG_INTERNAL="http://localhost:8082"
+GALLEY_INTERNAL="http://localhost:8085"
+API_URL="${API_URL:-http://localhost:8080}" # Wire API URL (via nginx)
+VERBOSE=${VERBOSE:-1}
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+log() {
+ if [ "$VERBOSE" -eq 1 ]; then
+ echo -e "${GREEN}[INFO]${NC} $*" >&2
+ fi
+}
+
+log_error() {
+ echo -e "${RED}[ERROR]${NC} $*" >&2
+}
+
+log_warn() {
+ echo -e "${YELLOW}[WARN]${NC} $*" >&2
+}
+
+# Generate random string
+random_string() {
+ local length=${1:-8}
+ env LC_CTYPE=C tr -dc a-zA-Z0-9 < /dev/urandom | head -c "$length"
+}
+
+# Create a team admin user via Brig internal API
+create_team_admin() {
+ local email
+ local password
+ local name
+ email="$(random_string)@example.com"
+ password="$(random_string 12)"
+ name="Test Admin"
+
+ log "Creating team admin user: $email"
+
+ local response
+ response=$(curl -s -w "\n%{http_code}" -X POST "$BRIG_INTERNAL/i/users" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"email\": \"$email\",
+ \"password\": \"$password\",
+ \"name\": \"$name\",
+ \"team\": {
+ \"name\": \"Test Team\",
+ \"icon\": \"default\"
+ }
+ }")
+
+ local http_code
+ http_code=$(echo "$response" | tail -n1)
+ local body
+ body=$(echo "$response" | head -n-1)
+
+ if [ "$http_code" != "201" ]; then
+ log_error "Failed to create team admin (HTTP $http_code)"
+ echo "$body" >&2
+ return 1
+ fi
+
+ local user_id
+ user_id=$(echo "$body" | jq -r '.id')
+ local team_id
+ team_id=$(echo "$body" | jq -r '.team')
+
+ if [ -z "$user_id" ] || [ "$user_id" = "null" ]; then
+ log_error "Failed to extract user ID from response"
+ return 1
+ fi
+
+ log "Created user: $user_id on team: $team_id"
+
+ # Export for use by caller
+ echo "$email|$password|$user_id|$team_id"
+}
+
+# Login and get access token
+login() {
+ local email="$1"
+ local password="$2"
+
+ log "Logging in as $email"
+
+ local response
+ response=$(curl -s -w "\n%{http_code}" -X POST "$BRIG_INTERNAL/login?persist=true" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"email\": \"$email\",
+ \"password\": \"$password\"
+ }")
+
+ local http_code
+ http_code=$(echo "$response" | tail -n1)
+ local body
+ body=$(echo "$response" | head -n-1)
+
+ if [ "$http_code" != "200" ]; then
+ log_error "Login failed (HTTP $http_code)"
+ echo "$body" >&2
+ return 1
+ fi
+
+ local access_token
+ access_token=$(echo "$body" | jq -r '.access_token')
+
+ if [ -z "$access_token" ] || [ "$access_token" = "null" ]; then
+ log_error "Failed to extract access token from response"
+ return 1
+ fi
+
+ log "Logged in successfully"
+ echo "$access_token"
+}
+
+# Enable channels feature for a team
+enable_channels() {
+ local team_id="$1"
+
+ log "Unlocking channels feature for team $team_id"
+
+ # First unlock the feature
+ local unlock_response
+ unlock_response=$(curl -s -w "\n%{http_code}" -X PUT "$GALLEY_INTERNAL/i/teams/$team_id/features/channels/unlocked")
+
+ local unlock_code
+ unlock_code=$(echo "$unlock_response" | tail -n1)
+ if [ "$unlock_code" != "200" ]; then
+ log_warn "Failed to unlock channels feature (HTTP $unlock_code), continuing anyway..."
+ fi
+
+ log "Enabling channels feature for team $team_id"
+
+ # Now enable the feature
+ local response
+ response=$(curl -s -w "\n%{http_code}" -X PATCH "$GALLEY_INTERNAL/i/teams/$team_id/features/channels" \
+ -H "Content-Type: application/json" \
+ -d '{"status": "enabled"}')
+
+ local http_code
+ http_code=$(echo "$response" | tail -n1)
+ local body
+ body=$(echo "$response" | head -n-1)
+
+ if [ "$http_code" != "200" ]; then
+ log_error "Failed to enable channels feature (HTTP $http_code)"
+ echo "$body" >&2
+ return 1
+ fi
+
+ log "Channels feature enabled"
+
+ # Verify the feature was enabled
+ log "Verifying channels feature status..."
+ local verify_response
+ verify_response=$(curl -s -X GET "$GALLEY_INTERNAL/i/teams/$team_id/features/channels")
+ log "Feature status: $verify_response"
+}
+
+# Create a user group
+create_user_group() {
+ local user_id="$1"
+ local auth_token="$2"
+ local name="$3"
+ local members="$4" # JSON array of user IDs
+
+ log "Creating user group: $name"
+
+ local response
+ response=$(curl -s -w "\n%{http_code}" -X POST "$BRIG_INTERNAL/v12/user-groups" \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer $auth_token" \
+ -H "Z-User: $user_id" \
+ -d "{
+ \"name\": \"$name\",
+ \"members\": $members
+ }")
+
+ local http_code
+ http_code=$(echo "$response" | tail -n1)
+ local body
+ body=$(echo "$response" | head -n-1)
+
+ if [ "$http_code" != "200" ] && [ "$http_code" != "201" ]; then
+ log_error "Failed to create user group (HTTP $http_code)"
+ echo "$body" >&2
+ return 1
+ fi
+
+ local group_id
+ group_id=$(echo "$body" | jq -r '.id')
+
+ if [ -z "$group_id" ] || [ "$group_id" = "null" ]; then
+ log_error "Failed to extract group ID from response"
+ return 1
+ fi
+
+ log "Created user group: $group_id"
+ echo "$group_id"
+}
+
+# Main test flow
+main() {
+ log "Starting end-to-end test for entreprise-provisioning"
+ log ""
+
+ # Step 1: Create team admin
+ log "=== Step 1: Creating team admin ==="
+ local user_data
+ if ! user_data=$(create_team_admin); then
+ log_error "Failed to create team admin"
+ exit 1
+ fi
+
+ local email
+ email=$(echo "$user_data" | cut -d'|' -f1)
+ local password
+ password=$(echo "$user_data" | cut -d'|' -f2)
+ local user_id
+ user_id=$(echo "$user_data" | cut -d'|' -f3)
+ local team_id
+ team_id=$(echo "$user_data" | cut -d'|' -f4)
+
+ log ""
+
+ # Step 2: Login
+ log "=== Step 2: Logging in ==="
+ local auth_token
+ if ! auth_token=$(login "$email" "$password"); then
+ log_error "Failed to login"
+ exit 1
+ fi
+
+ log ""
+
+ # Step 3: Enable channels
+ log "=== Step 3: Enabling channels feature ==="
+ if ! enable_channels "$team_id"; then
+ log_error "Failed to enable channels"
+ exit 1
+ fi
+
+ log ""
+
+ # Step 4: Create user groups
+ log "=== Step 4: Creating user groups ==="
+ local group1_id
+ if ! group1_id=$(create_user_group "$user_id" "$auth_token" "Engineering" "[]"); then
+ log_error "Failed to create user group 1"
+ exit 1
+ fi
+
+ local group2_id
+ if ! group2_id=$(create_user_group "$user_id" "$auth_token" "Marketing" "[]"); then
+ log_error "Failed to create user group 2"
+ exit 1
+ fi
+
+ local group3_id
+ if ! group3_id=$(create_user_group "$user_id" "$auth_token" "Sales" "[]"); then
+ log_error "Failed to create user group 3"
+ exit 1
+ fi
+
+ log ""
+
+ # Step 5: Create input JSON
+ log "=== Step 5: Creating input JSON ==="
+ local input_file
+ input_file=$(mktemp)
+ cat > "$input_file" <&2
+ fi
+
+ log ""
+
+ # Step 6: Run CLI tool
+ log "=== Step 6: Running entreprise-provisioning CLI ==="
+ local output_file
+ output_file=$(mktemp)
+
+ log "Running CLI tool via cabal"
+
+ local cli_opts=()
+ if [ "$VERBOSE" -eq 1 ]; then
+ cli_opts+=("-v")
+ fi
+
+ cabal run entreprise-provisioning -- user-groups channels \
+ -t "$team_id" \
+ -u "$user_id" \
+ --api-url "$API_URL" \
+ --auth-token "$auth_token" \
+ -f "$input_file" \
+ "${cli_opts[@]}" \
+ > "$output_file" 2>&1
+
+ # Extract only the JSON from the output (skip cabal build messages)
+ local json_file
+ json_file=$(mktemp)
+ tail -n 1 "$output_file" > "$json_file"
+ mv "$json_file" "$output_file"
+
+ local cli_exit_code=$?
+
+ if [ $cli_exit_code -ne 0 ]; then
+ log_error "CLI tool failed with exit code $cli_exit_code"
+ log_error "Output:"
+ cat "$output_file" >&2
+ rm -f "$input_file" "$output_file"
+ exit 1
+ fi
+
+ log "CLI tool completed successfully"
+ log ""
+
+ # Step 7: Verify results
+ log "=== Step 7: Verifying results ==="
+ log "Output file: $output_file"
+
+ if ! jq empty "$output_file" 2>/dev/null; then
+ log_error "Output is not valid JSON"
+ cat "$output_file" >&2
+ rm -f "$input_file" "$output_file"
+ exit 1
+ fi
+
+ log "Output is valid JSON"
+
+ # Check that we have results for all 3 groups
+ local result_groups
+ result_groups=$(jq -r 'keys[]' "$output_file")
+ local result_count
+ result_count=$(echo "$result_groups" | wc -l)
+
+ if [ "$result_count" -ne 3 ]; then
+ log_error "Expected 3 groups in results, got $result_count"
+ rm -f "$input_file" "$output_file"
+ exit 1
+ fi
+
+ log "All 3 groups present in results"
+
+ # Check for successful channels and associations
+ local total_channels
+ total_channels=$(jq '[.[] | .channel[]] | length' "$output_file")
+ local successful_channels
+ successful_channels=$(jq '[.[] | .channel[] | select(.id)] | length' "$output_file")
+ local failed_channels
+ failed_channels=$(jq '[.[] | .channel[] | select(.failure)] | length' "$output_file")
+ local successful_associations
+ successful_associations=$(jq '[.[] | .association | select(.success == true)] | length' "$output_file")
+
+ log "Total channels: $total_channels"
+ log "Successful channels: $successful_channels"
+ log "Failed channels: $failed_channels"
+ log "Successful associations: $successful_associations"
+
+ if [ "$successful_channels" -eq 0 ]; then
+ log_error "No channels were created successfully"
+ log "Full output:"
+ jq '.' "$output_file" >&2
+ rm -f "$input_file" "$output_file"
+ exit 1
+ fi
+
+ if [ "$successful_associations" -eq 0 ]; then
+ log_error "No associations were successful"
+ log "Full output:"
+ jq '.' "$output_file" >&2
+ rm -f "$input_file" "$output_file"
+ exit 1
+ fi
+
+ log ""
+ log "=== Test Summary ==="
+ log "Team ID: $team_id"
+ log "User ID: $user_id"
+ log "User Groups Created: 3"
+ log "- Engineering ($group1_id): 3 channels"
+ log "- Marketing ($group2_id): 2 channels"
+ log "- Sales ($group3_id): 3 channels"
+ log "Total Channels Created: $successful_channels/$total_channels"
+ log "Successful Associations: $successful_associations/3"
+
+ if [ "$VERBOSE" -eq 1 ]; then
+ log ""
+ log "=== Full Results ==="
+ jq '.' "$output_file" >&2
+ fi
+
+ log ""
+ log -e "${GREEN}✓ All tests passed!${NC}"
+
+ # Cleanup
+ rm -f "$input_file" "$output_file"
+}
+
+# Run main
+main "$@"
diff --git a/tools/entreprise-provisioning/test/Main.hs b/tools/entreprise-provisioning/test/Main.hs
new file mode 100644
index 0000000000..bdee1393a3
--- /dev/null
+++ b/tools/entreprise-provisioning/test/Main.hs
@@ -0,0 +1,163 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2025 Wire Swiss GmbH
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Main (main) where
+
+import Data.Aeson (eitherDecode, encode, object, (.=))
+import Data.Aeson.Encode.Pretty (encodePretty)
+import Data.ByteString.Lazy qualified as LBS
+import Data.Id
+import Data.Map.Strict qualified as Map
+import Data.Range
+import Imports
+import Test.Tasty
+import Test.Tasty.Golden (goldenVsString)
+import Test.Tasty.HUnit
+import Types
+
+main :: IO ()
+main =
+ defaultMain $
+ testGroup
+ "Tests"
+ [ testGroup
+ "Unit Tests"
+ [ testCase "Parse valid input JSON" testParseValidInput,
+ testCase "Serialize success output" testSerializeSuccessOutput,
+ testCase "Serialize partial failure output" testSerializePartialFailureOutput,
+ testCase "Serialize association failure output" testSerializeAssociationFailureOutput
+ ],
+ testGroup
+ "Golden Tests"
+ [ goldenVsString
+ "Success output format"
+ "test/golden/output-success.json"
+ (pure $ encodePretty mockSuccessOutput),
+ goldenVsString
+ "Partial failure output format"
+ "test/golden/output-partial-failure.json"
+ (pure $ encodePretty mockPartialFailureOutput),
+ goldenVsString
+ "Association failure output format"
+ "test/golden/output-association-failure.json"
+ (pure $ encodePretty mockAssociationFailureOutput)
+ ]
+ ]
+
+-- | Test parsing valid input JSON
+testParseValidInput :: IO ()
+testParseValidInput = do
+ content <- LBS.readFile "test/golden/input-valid.json"
+ case eitherDecode content of
+ Left err -> assertFailure $ "Failed to parse input: " ++ err
+ Right (UserGroupChannelsProvisionningSpec input) -> do
+ length (Map.keys input) @?= 4
+ assertBool "Input should contain user groups" (not (Map.null input))
+
+-- | Test serializing success output
+testSerializeSuccessOutput :: IO ()
+testSerializeSuccessOutput = do
+ let output = encode mockSuccessOutput
+ assertBool "Output should not be empty" (LBS.length output > 0)
+
+-- | Test serializing partial failure output
+testSerializePartialFailureOutput :: IO ()
+testSerializePartialFailureOutput = do
+ let output = encode mockPartialFailureOutput
+ assertBool "Output should not be empty" (LBS.length output > 0)
+
+-- | Test serializing association failure output
+testSerializeAssociationFailureOutput :: IO ()
+testSerializeAssociationFailureOutput = do
+ let output = encode mockAssociationFailureOutput
+ assertBool "Output should not be empty" (LBS.length output > 0)
+
+-- * Fixtures
+
+-- | Mock successful output
+mockSuccessOutput :: UserGroupChannelsProvisionningResult
+mockSuccessOutput =
+ UserGroupChannelsProvisionningResult $
+ Map.fromList
+ [ ( mkId "00000000-0000-0000-0000-0000000000a0",
+ UserGroupResult
+ { channel =
+ [ ChannelSuccess (mkChannelName "channel name 0") (mkId "00000000-0000-0000-0000-000000000000"),
+ ChannelSuccess (mkChannelName "channel name 1") (mkId "00000000-0000-0000-0000-000000000001")
+ ],
+ association = AssociationSuccess
+ }
+ ),
+ ( mkId "00000000-0000-0000-0000-0000000000a1",
+ UserGroupResult
+ { channel =
+ [ ChannelSuccess (mkChannelName "channel name 0") (mkId "00000000-0000-0000-0000-000000000010")
+ ],
+ association = AssociationSuccess
+ }
+ )
+ ]
+
+-- | Mock partial failure output (some channels fail to create)
+mockPartialFailureOutput :: UserGroupChannelsProvisionningResult
+mockPartialFailureOutput =
+ UserGroupChannelsProvisionningResult $
+ Map.fromList
+ [ ( mkId "00000000-0000-0000-0000-0000000000a0",
+ UserGroupResult
+ { channel =
+ [ ChannelSuccess (mkChannelName "channel name 0") (mkId "00000000-0000-0000-0000-000000000000"),
+ ChannelFailure
+ (mkChannelName "channel name 1")
+ ( ErrorDetail
+ 409
+ (object ["label" .= ("invalid-op" :: Text), "message" .= ("Channel already exists" :: Text)])
+ )
+ ],
+ association = AssociationSuccess
+ }
+ )
+ ]
+
+-- | Mock association failure output (channels created but association fails)
+mockAssociationFailureOutput :: UserGroupChannelsProvisionningResult
+mockAssociationFailureOutput =
+ UserGroupChannelsProvisionningResult $
+ Map.fromList
+ [ ( mkId "00000000-0000-0000-0000-0000000000a0",
+ UserGroupResult
+ { channel =
+ [ ChannelSuccess (mkChannelName "channel name 0") (mkId "00000000-0000-0000-0000-000000000000"),
+ ChannelSuccess (mkChannelName "channel name 1") (mkId "00000000-0000-0000-0000-000000000001")
+ ],
+ association =
+ AssociationFailureResult
+ ( ErrorDetail
+ 404
+ (object ["label" .= ("not-found" :: Text), "message" .= ("User group not found" :: Text)])
+ )
+ }
+ )
+ ]
+
+-- * Unsafe helpers
+
+mkId :: String -> Id a
+mkId s = Id (read s)
+
+mkChannelName :: Text -> ChannelName
+mkChannelName t = ChannelName (unsafeRange t)
diff --git a/tools/entreprise-provisioning/test/golden/input-valid.json b/tools/entreprise-provisioning/test/golden/input-valid.json
new file mode 100644
index 0000000000..ffdcede961
--- /dev/null
+++ b/tools/entreprise-provisioning/test/golden/input-valid.json
@@ -0,0 +1,6 @@
+{
+ "00000000-0000-0000-0000-0000000000a0": ["channel name 0", "channel name 1", "channel name 2", "channel name 3"],
+ "00000000-0000-0000-0000-0000000000a1": ["channel name 0", "channel name 1"],
+ "00000000-0000-0000-0000-0000000000a2": ["channel name 0", "channel name 1", "channel name 2"],
+ "00000000-0000-0000-0000-0000000000a3": ["channel name 0"]
+}
diff --git a/tools/entreprise-provisioning/test/golden/output-association-failure.json b/tools/entreprise-provisioning/test/golden/output-association-failure.json
new file mode 100644
index 0000000000..5cd442eeb4
--- /dev/null
+++ b/tools/entreprise-provisioning/test/golden/output-association-failure.json
@@ -0,0 +1,24 @@
+{
+ "00000000-0000-0000-0000-0000000000a0": {
+ "association": {
+ "detail": {
+ "response": {
+ "label": "not-found",
+ "message": "User group not found"
+ },
+ "status": 404
+ },
+ "success": false
+ },
+ "channel": [
+ {
+ "id": "00000000-0000-0000-0000-000000000000",
+ "name": "channel name 0"
+ },
+ {
+ "id": "00000000-0000-0000-0000-000000000001",
+ "name": "channel name 1"
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/tools/entreprise-provisioning/test/golden/output-partial-failure.json b/tools/entreprise-provisioning/test/golden/output-partial-failure.json
new file mode 100644
index 0000000000..71c48afe92
--- /dev/null
+++ b/tools/entreprise-provisioning/test/golden/output-partial-failure.json
@@ -0,0 +1,23 @@
+{
+ "00000000-0000-0000-0000-0000000000a0": {
+ "association": {
+ "success": true
+ },
+ "channel": [
+ {
+ "id": "00000000-0000-0000-0000-000000000000",
+ "name": "channel name 0"
+ },
+ {
+ "failure": {
+ "response": {
+ "label": "invalid-op",
+ "message": "Channel already exists"
+ },
+ "status": 409
+ },
+ "name": "channel name 1"
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/tools/entreprise-provisioning/test/golden/output-success.json b/tools/entreprise-provisioning/test/golden/output-success.json
new file mode 100644
index 0000000000..51d763c09d
--- /dev/null
+++ b/tools/entreprise-provisioning/test/golden/output-success.json
@@ -0,0 +1,28 @@
+{
+ "00000000-0000-0000-0000-0000000000a0": {
+ "association": {
+ "success": true
+ },
+ "channel": [
+ {
+ "id": "00000000-0000-0000-0000-000000000000",
+ "name": "channel name 0"
+ },
+ {
+ "id": "00000000-0000-0000-0000-000000000001",
+ "name": "channel name 1"
+ }
+ ]
+ },
+ "00000000-0000-0000-0000-0000000000a1": {
+ "association": {
+ "success": true
+ },
+ "channel": [
+ {
+ "id": "00000000-0000-0000-0000-000000000010",
+ "name": "channel name 0"
+ }
+ ]
+ }
+}
\ No newline at end of file