From 5d6fe5572bb357964617cea908f966105708870a Mon Sep 17 00:00:00 2001 From: Simen Kirkvik Date: Wed, 14 Jan 2026 15:37:19 +0100 Subject: [PATCH] codex: Add ability to update user permissions - Active - Registered - Disabled --- global.json | 2 +- src/Codex/src/Client/Codex.Client.fsproj | 1 + src/Codex/src/Client/OpenFGA/Checkbox.fs | 52 ++++++++++++++++++---- src/Codex/src/Client/Utils.fs | 7 +++ src/Codex/src/Server/Admin.fs | 56 ++++++++++++++++++++++-- src/Codex/src/Server/OpenFGA.fs | 44 ++++++++++++++++--- src/Codex/src/Shared/Remoting.fs | 21 +++++++-- 7 files changed, 160 insertions(+), 23 deletions(-) create mode 100644 src/Codex/src/Client/Utils.fs diff --git a/global.json b/global.json index f98e01d6..ba3d1477 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.0", + "version": "10.0.100", "rollForward": "latestMinor" } } \ No newline at end of file diff --git a/src/Codex/src/Client/Codex.Client.fsproj b/src/Codex/src/Client/Codex.Client.fsproj index ea2fb3e7..bf768d66 100644 --- a/src/Codex/src/Client/Codex.Client.fsproj +++ b/src/Codex/src/Client/Codex.Client.fsproj @@ -9,6 +9,7 @@ + diff --git a/src/Codex/src/Client/OpenFGA/Checkbox.fs b/src/Codex/src/Client/OpenFGA/Checkbox.fs index bc520c96..ccafe949 100644 --- a/src/Codex/src/Client/OpenFGA/Checkbox.fs +++ b/src/Codex/src/Client/OpenFGA/Checkbox.fs @@ -6,17 +6,47 @@ open Feliz [] type OpenFGA = + [] + static member private Spinner() = + JSX.html + $""" + import {{ Spinner }} from "@fluentui/react-components"; + + + """ + [] static member Checkbox(key, label: string, user: string, relation: string, object: string) = + let isLoading, setLoading = React.useState false let isChecked, setChecked = React.useState false let handleChange (ev: Types.Event) = - console.debug("[OpenFGA.Checkbox] Checkbox %s changed to %o", key, not isChecked) + console.debug("[OpenFGA.Checkbox] Checkbox %s for user %s rel %s changed to %o", key, user, relation, not isChecked) // TODO: Write to OpenFGA - setChecked(not isChecked) + let newChecked = not isChecked + // setChecked + setLoading true + Remoting.adminApi.setUserPermissions { + User = user + Permissions = [| + { Name = relation; Enabled = newChecked } + |] + } + |> Async.StartAsPromise + |> Promise.iter (fun res -> + match res with + | Ok () -> + console.debug("Success.") + setChecked newChecked + | Error err -> + console.error("Error: %s", err) + setLoading false + ) React.useEffect ( (fun () -> + setLoading true + Remoting.openFgaApi.Check { User = user Relation = relation @@ -30,6 +60,8 @@ type OpenFGA = setChecked hasRelation | Error err -> console.error("[OpenFGA.Checkbox] Error checking user %s has relation %s to %s", user, relation, object) + + setLoading false ) ), [| |] @@ -38,12 +70,15 @@ type OpenFGA = Html.div [ prop.classes [ "flex-row-center" ] prop.children [ - Html.input [ - prop.id (sprintf "openfga-checkbox-%s" key) - prop.type'.checkbox - prop.onChange handleChange - prop.custom("checked", isChecked) - ] + if isLoading then + OpenFGA.Spinner() |> Utils.toReact + else + Html.input [ + prop.id (sprintf "openfga-checkbox-%s" key) + prop.type'.checkbox + prop.onChange handleChange + prop.custom("checked", isChecked) + ] Html.label [ prop.htmlFor (sprintf "openfga-checkbox-%s" key) @@ -51,4 +86,3 @@ type OpenFGA = ] ] ] - diff --git a/src/Codex/src/Client/Utils.fs b/src/Codex/src/Client/Utils.fs new file mode 100644 index 00000000..a533a2e1 --- /dev/null +++ b/src/Codex/src/Client/Utils.fs @@ -0,0 +1,7 @@ +namespace Oceanbox.Codex + +module Utils = + open Fable.Core + open Feliz + + let toReact (el: JSX.Element) : ReactElement = unbox el \ No newline at end of file diff --git a/src/Codex/src/Server/Admin.fs b/src/Codex/src/Server/Admin.fs index 53ef2ec6..7e9ae5a7 100644 --- a/src/Codex/src/Server/Admin.fs +++ b/src/Codex/src/Server/Admin.fs @@ -528,22 +528,70 @@ module Admin = return () } + let setUserPermissions (ctx: HttpContext) (req: Remoting.UserPermissionRequest) = + async { + let user = ctx.User.Identity.Name + let logger = ctx.GetLogger () + do logger.LogInformation ("setUserPermissions from {User}: {@Req}", user, req) + // TODO(simkir): Sanitize/check the request, aka turn the dto to an internal type + try + let fga = ctx.GetService () + let writes: Model.ClientWriteRequest = + req.Permissions + |> Array.choose (fun permission -> + if permission.Enabled then + Some { + Remoting.Tuple.empty with + User = req.User + Relation = permission.Name + Object = req.User + } + else + None + ) + |> OpenFGA.Queries.write + if writes.Writes.Count > 0 then + let! fgaWriteResp = fga.Write writes |> Async.AwaitTask + do logger.LogInformation ("OpenFGA write responded with: {JSON}", fgaWriteResp.ToJson ()) + + let deletes = + req.Permissions + |> Array.choose (fun permission -> + if permission.Enabled then + None + else + Remoting.Tuple.delete(req.User, permission.Name, req.User) + |> Some + ) + |> OpenFGA.Queries.deleteTuples + if deletes.Count > 0 then + let! fgaDeleteResp = fga.DeleteTuples deletes |> Async.AwaitTask + do logger.LogInformation ("OpenFGA delete responded with: {JSON}", fgaDeleteResp.ToJson ()) + + return Ok () + with e -> + do logger.LogError (e, "Error setting user permissions") + // TODO: Maybe do not send exn message + return Error (sprintf "Error setting user permissions: %s" e.Message) + } + let private impl (ctx: HttpContext) : Remoting.Api.Admin = { - addUsers = Handler.addUsers ctx + addArchive = Handler.addArchive ctx addArchiveGroups = Handler.addArchiveGroups ctx addGroupPermissions = Handler.addGroupPermissions ctx + addUsers = Handler.addUsers ctx deleteArchive = Handler.deleteArchive ctx getAllGroups = Handler.getAllGroups ctx getArchive = Handler.getArchive ctx getArchiveCount = Handler.getArchiveCount ctx + getArchiveDataSet = Handler.getArchiveDataSet ctx getArchiveRefs = Handler.getArchiveRefs ctx getArchiveTypes = fun () -> Handler.getArchiveTypes ctx getArchives = Handler.getArchives ctx + getDataSets = fun () -> Handler.getAllDataSets ctx getGroupUsers = Handler.getGroupUsers ctx removeUsers = Handler.removeUsers ctx - getDataSets = fun () -> Handler.getAllDataSets ctx - getArchiveDataSet = Handler.getArchiveDataSet ctx - addArchive = Handler.addArchive ctx + setUserPermissions = Handler.setUserPermissions ctx updateArchive = Handler.updateArchive ctx } diff --git a/src/Codex/src/Server/OpenFGA.fs b/src/Codex/src/Server/OpenFGA.fs index 814cf178..17392986 100644 --- a/src/Codex/src/Server/OpenFGA.fs +++ b/src/Codex/src/Server/OpenFGA.fs @@ -71,7 +71,8 @@ module OpenFGA = |> Seq.toArray |> Array.map (fun t -> let condition : Remoting.Condition option = - Option.ofObj t.Key.Condition + t.Key.Condition + |> Option.ofObj |> Option.map (fun cond -> { Name = cond.Name Context = JsonSerializer.Serialize cond.Context @@ -176,7 +177,23 @@ module OpenFGA = result - let delete' (tuples: ClientTupleKey array) = + /// To be used with OpenFga.Sdk.Client.OpenFgaClient.DeleteTuples + let deleteTuples (tuples: Remoting.Tuple array) : ResizeArray = + let deletes: ClientTupleKeyWithoutCondition array = + tuples + |> Array.map (fun tuple -> + let result = ClientTupleKeyWithoutCondition () + + do result.Object <- tuple.Object + do result.Relation <- tuple.Relation + do result.User <- tuple.User + + result + ) + + ResizeArray deletes + + let delete' (tuples: ClientTupleKey array) : ClientWriteRequest = let result = ClientWriteRequest () let deletes: ClientTupleKeyWithoutCondition array = @@ -195,6 +212,22 @@ module OpenFGA = result + /// To be used with OpenFga.Sdk.Client.OpenFgaClient.DeleteTuples + let deleteTuples' (tuples: ClientTupleKey array) : ResizeArray = + let deletes: ClientTupleKeyWithoutCondition array = + tuples + |> Array.map (fun tuple -> + let result = ClientTupleKeyWithoutCondition () + + do result.Object <- tuple.Object + do result.Relation <- tuple.Relation + do result.User <- tuple.User + + result + ) + + ResizeArray deletes + let write (tuples: Remoting.Tuple array) = let result = ClientWriteRequest () @@ -310,9 +343,10 @@ module OpenFGA = let logger = ctx.GetLogger () let fga = ctx.GetService () try - let deleteRequest = Queries.delete [| tuple |] - do logger.LogInformation ("Delete req: {Request}", deleteRequest.ToJson ()) - let! resp = fga.Write deleteRequest |> Async.AwaitTask + let deleteRequest = Queries.deleteTuples [| tuple |] + let json = JsonSerializer.Serialize deleteRequest + do logger.LogInformation ("Delete req: {Request}", json) + let! resp = fga.DeleteTuples deleteRequest |> Async.AwaitTask do logger.LogInformation ("Delete resp: {Response}", resp.ToJson ()) return Ok (resp.Deletes.Count >= 1) with e -> diff --git a/src/Codex/src/Shared/Remoting.fs b/src/Codex/src/Shared/Remoting.fs index 5475295b..44e3c1c7 100644 --- a/src/Codex/src/Shared/Remoting.fs +++ b/src/Codex/src/Shared/Remoting.fs @@ -196,26 +196,39 @@ module Remoting = Permissions: ArchiveRelation array } + [] + type Permission = { + Name: string + Enabled: bool + } + + [] + type UserPermissionRequest = { + User: string + Permissions: Permission array + } + [] module Api = type Auth = { IsAuthenticated: Async } type Admin = { - addUsers: AddUsersRequest -> Async> + addArchive: AddArchiveRequest -> Async> addArchiveGroups: AddArchiveGroupsRequest -> Async> addGroupPermissions: AddGroupPermissionsRequest -> Async> + addUsers: AddUsersRequest -> Async> deleteArchive: Archmaester.Dto.ArchiveId -> Async> getAllGroups: Async getArchive: Archmaester.Dto.ArchiveId -> Async> getArchiveCount: Archmaester.Dto.ArchiveFilter -> Async> + getArchiveDataSet: System.Guid -> Async> getArchiveRefs: Archmaester.Dto.ArchiveFilter -> Async> getArchiveTypes: unit -> Async getArchives: int -> int -> Archmaester.Dto.ArchiveFilter -> Async> + getDataSets: unit -> Async> getGroupUsers: string -> Async removeUsers: string array -> Async> - getDataSets: unit -> Async> - getArchiveDataSet: System.Guid -> Async> - addArchive: AddArchiveRequest -> Async> + setUserPermissions: UserPermissionRequest -> Async> updateArchive: System.Guid -> EditArchiveRequest -> Async> }