codex: Allow for update of group archive permissions

This commit is contained in:
2026-01-15 15:59:28 +01:00
parent 3e61cfb939
commit 2bf0d82a5b
3 changed files with 477 additions and 165 deletions

View File

@@ -1,5 +1,10 @@
namespace Oceanbox.Codex
type private Permission = {
Tuple: Remoting.Tuple
Relation: Remoting.ArchiveRelation
}
module GroupArchive =
open Browser
open Fable.Core
@@ -19,6 +24,18 @@ module GroupArchive =
return res
}
let private putPermissions (group: string) (archiveId: System.Guid) (permissions: Remoting.ArchiveRelation array) =
promise {
console.debug("Updating existing relations: %o", permissions)
let req : Remoting.AddGroupPermissionsRequest = {
Group = Groups.Utils.canonicalizeName group
ArchiveId = archiveId
Permissions = permissions
}
let! res = Remoting.adminApi.updateGroupPermissions req |> Async.StartAsPromise
return res
}
[<ReactComponent>]
let private DeleteRelationButton onDelete (tuple: Remoting.Tuple) =
let handleDelete (ev: Types.Event) =
@@ -43,61 +60,127 @@ module GroupArchive =
]
[<ReactComponent>]
let private ViewTerm
(onDelete: Remoting.Tuple -> unit)
(viewTerm: Remoting.ViewTerm)
(tuple: Remoting.Tuple)
=
let private PermissionCard (title: string) onDelete (tuple: Remoting.Tuple) (children: ReactElement array) =
Html.div [
prop.classes [ "flex-column"; "gap-8"; "shadow"; "brad-8"; "m-8"; "p-16" ]
prop.style [ style.flexBasis (length.px 320) ]
prop.style [ style.flexBasis (length.px 384) ]
prop.children [
Html.div [
prop.classes [ "flex-row-center" ]
prop.children [
Html.div [
prop.classes [ "grow" ]
prop.children [ Html.b "View Term" ]
prop.children [
Html.b [
prop.style [
style.fontSize(length.px 16)
]
prop.text title
]
]
]
DeleteRelationButton onDelete tuple
]
]
Html.div [
prop.classes [ "ml-16" ]
prop.children [
Html.div (sprintf "Start time: %s" (Intl.shortDateTime viewTerm.StartTime))
Html.div (sprintf "End time: %s" (Intl.shortDateTime viewTerm.EndTime))
]
prop.children children
]
]
]
[<ReactComponent>]
let private ExecTicket onDelete (ticket: Remoting.ExecTicket) (tuple: Remoting.Tuple) =
Html.div [
prop.classes [ "flex-column"; "gap-8"; "shadow"; "brad-8"; "m-8"; "p-16" ]
prop.style [ style.flexBasis (length.px 320) ]
prop.children [
Html.div [
prop.classes [ "flex-row-center" ]
prop.children [
Html.div [ prop.classes [ "grow" ]; prop.children [ Html.b "Exec Ticket" ] ]
let private ViewTerm
key
(onUpdate: Permission -> unit)
(permission: Permission)
(viewTerm: Remoting.ViewTerm)
=
let updateCond (newCond: Remoting.ViewTerm) =
let updatedCond =
permission.Tuple.Condition
|> Option.map (fun cond ->
{ cond with Context = JS.JSON.stringify newCond.JsonObj }
)
onUpdate {
permission with
Tuple.Condition = updatedCond
Relation = Remoting.ArchiveRelation.ViewTerm newCond
}
DeleteRelationButton onDelete tuple
]
]
Html.div [
prop.classes [ "ml-16" ]
prop.children [
Html.div (sprintf "Start time: %s" (Intl.shortDateTime ticket.StartTime))
Html.div (sprintf "End time: %s" (Intl.shortDateTime ticket.EndTime))
Html.div (sprintf "Quota: %.1f" ticket.Quota)
Html.div [
prop.children [
Html.span "Tasks:"
Html.ul [
prop.children (ticket.Tasks |> Array.map (fun task -> Html.li task))
let handleStartChange =
React.useCallback (
(fun (newStartOpt: System.DateTime option) ->
match newStartOpt with
| Some newStart ->
let updated = { viewTerm with StartTime = newStart }
updateCond updated
| None ->
console.error("Got no date from date picker")
),
[| permission |]
)
let handleEndChange =
React.useCallback (
(fun (newEndOpt: System.DateTime option) ->
match newEndOpt with
| Some newEnd ->
let updated = { viewTerm with EndTime = newEnd }
updateCond updated
| None ->
console.error("Got no date from date picker")
),
[| permission |]
)
Fui.table [
table.size.medium
table.children [
Fui.tableBody [
tableBody.children [
Fui.tableRow [
tableRow.key "view-term-start-time"
tableRow.children [
Fui.tableCell [
tableCell.text "Start time"
]
Fui.tableCell [
tableCell.children (
Fui.tableCellLayout [
tableCellLayout.children (
Fui.datePicker [
datePicker.size.small
datePicker.onSelectDate handleStartChange
datePicker.value (Some viewTerm.StartTime)
]
)
]
)
]
]
]
Fui.tableRow [
tableRow.key "view-term-end-time"
tableRow.children [
Fui.tableCell [
tableCell.text "End time"
]
Fui.tableCell [
tableCell.children (
Fui.tableCellLayout [
tableCellLayout.children (
Fui.datePicker [
datePicker.size.small
datePicker.onSelectDate handleEndChange
datePicker.value (Some viewTerm.EndTime)
]
)
]
)
]
]
]
@@ -106,88 +189,279 @@ module GroupArchive =
]
]
[<ReactComponent>]
let private ExecTicket
(key: string)
(onUpdate: Permission -> unit)
(permission: Permission)
(ticket: Remoting.ExecTicket)
=
let updateCond (newCond: Remoting.ExecTicket) =
let updatedCond =
permission.Tuple.Condition
|> Option.map (fun cond ->
{ cond with Context = JS.JSON.stringify newCond.JsonObj }
)
onUpdate {
permission with
Tuple.Condition = updatedCond
Relation = Remoting.ArchiveRelation.ExecTicket newCond
}
let handleStartChange =
React.useCallback (
(fun (newStartOpt: System.DateTime option) ->
match newStartOpt with
| Some newStart ->
let updated = { ticket with StartTime = newStart }
updateCond updated
| None ->
console.error("Got no date from date picker")
),
[| ticket |]
)
let handleEndChange =
React.useCallback (
(fun (newEndOpt: System.DateTime option) ->
match newEndOpt with
| Some newEnd ->
let updated = { ticket with EndTime = newEnd }
updateCond updated
| None ->
console.error("Got no date from date picker")
),
[| ticket |]
)
Fui.table [
table.size.medium
table.children [
Fui.tableBody [
tableBody.children [
Fui.tableRow [
tableRow.key "exec-ticket-start-time"
tableRow.children [
Fui.tableCell [
tableCell.text "Start time"
]
Fui.tableCell [
tableCell.children (
Fui.tableCellLayout [
tableCellLayout.children (
Fui.datePicker [
datePicker.size.small
datePicker.onSelectDate handleStartChange
datePicker.value (Some ticket.StartTime)
]
)
]
)
]
]
]
Fui.tableRow [
tableRow.key "exec-ticket-end-time"
tableRow.children [
Fui.tableCell [
tableCell.text "End time"
]
Fui.tableCell [
tableCell.children (
Fui.tableCellLayout [
tableCellLayout.children (
Fui.datePicker [
datePicker.size.small
datePicker.onSelectDate handleEndChange
datePicker.value (Some ticket.EndTime)
]
)
]
)
]
]
]
Fui.tableRow [
tableRow.key "exec-ticket-quota"
tableRow.children [
Fui.tableCell [
tableCell.text "Quota"
]
Fui.tableCell [
tableCell.children (
Fui.tableCellLayout [
tableCellLayout.children (
Fui.input [
input.size.small
input.type'.number
input.value ticket.Quota
]
)
]
)
]
]
]
Fui.tableRow [
tableRow.key "exec-ticket-tasks"
tableRow.children [
Fui.tableCell [
tableCell.text "Tasks"
]
Fui.tableCell [
tableCell.children (
Fui.tableCellLayout [
tableCellLayout.children (
Fui.text (
ticket.Tasks
|> String.concat ", "
)
)
]
)
]
]
]
]
]
]
]
[<ReactComponent>]
let private PermissionCreateCard group archiveId (onCreate: Permission -> unit) (defaultPermission: Permission) =
let isLoading, setLoading = React.useState false
let permission, setPermission = React.useState defaultPermission
let handleCreate (ev: Types.Event) =
setLoading true
postPermissions group archiveId [| permission.Relation |]
|> Promise.iter (fun res ->
match res with
| Ok () ->
console.info("Success.")
onCreate permission
| Error msg ->
console.error("Error adding permissions %s.", msg)
setLoading false
)
let handleUpdateRelation (permission: Permission) =
setPermission permission
console.debug("Permission: %o", permission)
Html.div [
prop.classes [ "flex-column"; "gap-8"; "shadow"; "brad-8"; "m-8"; "p-16" ]
prop.style [ style.flexBasis (length.px 384) ]
prop.children [
Html.div [
prop.classes [ "flex-row-center" ]
prop.children [
Html.div [
prop.classes [ "grow" ]
prop.children [
Html.b [
prop.style [
style.fontSize(length.px 16)
]
prop.text (
match permission.Relation with
| Remoting.ArchiveRelation.ViewTerm _ -> "View Term"
| Remoting.ArchiveRelation.ExecTicket _ -> "Exec Ticket"
)
]
]
]
Fui.button [
button.onClick handleCreate
button.icon (
if isLoading then
Fui.spinner [ spinner.size.tiny ]
else
Fui.icon.addRegular []
)
]
]
]
Html.div [
prop.children [|
match permission.Relation with
| Remoting.ArchiveRelation.ViewTerm term ->
ViewTerm
"create-permission-view-term"
handleUpdateRelation
permission
term
| Remoting.ArchiveRelation.ExecTicket ticket ->
ExecTicket
"create-permission-exec-ticket"
handleUpdateRelation
permission
ticket
|]
]
]
]
let private allRelations = [|
Remoting.ArchiveRelation.ViewTerm Remoting.ViewTerm.empty
Remoting.ArchiveRelation.ExecTicket Remoting.ExecTicket.empty
|]
[<ReactComponent>]
let private PermissionForm
(group: string)
(archiveId: System.Guid)
(onUpdate: Remoting.Tuple array -> unit)
(tuples: Remoting.Tuple array)
(onAdd: Permission -> unit)
(permissions: Permission array)
=
let adding, setAdding = React.useState false
let loading, setLoading = React.useState false
let success, setSuccess = React.useState false
let newView, setNewView = React.useState<Remoting.ViewTerm option> None
let newExec, setNewExec = React.useState<Remoting.ExecTicket option> None
let newPermissions = [|
match newView with
| Some view -> Remoting.ArchiveRelation.ViewTerm view
| None -> ()
// Create a list of permissions missing from the archive
let availablePermissions : Permission array =
allRelations
|> Array.choose (fun relation ->
let exists =
permissions
|> Array.exists (fun permission ->
let name = Remoting.ArchiveRelation.ConditionName relation
permission.Tuple.Condition
|> Option.map (fun cond -> cond.Name = name)
|> Option.defaultValue false
)
match newExec with
| Some exec -> Remoting.ArchiveRelation.ExecTicket exec
| None -> ()
|]
// TODO: Go back to using .Is* when we can
let hasViewTerm =
tuples
|> Array.exists (fun tuple ->
match tuple.Relation with
| "view" -> true
| _ -> false
)
let hasExecTicket =
tuples
|> Array.exists (fun tuple ->
match tuple.Relation with
| "exec" -> true
| _ -> false
if exists then
None
else
let user = Groups.Utils.fgaMember group
let object = sprintf "archive:%O" archiveId
Some {
Tuple = OpenFGA.Types.ArchiveRelation.toTuple user object relation
Relation = relation
}
)
let handleAddClick (ev: Types.Event) =
if not hasViewTerm && newView.IsNone then
setNewView (Some Remoting.ViewTerm.empty)
if not hasExecTicket && newExec.IsNone then
setNewExec (Some Remoting.ExecTicket.empty)
setAdding true
let handleSaveClick =
React.useCallback (
(fun (ev: Types.Event) ->
setLoading true
postPermissions group archiveId newPermissions
|> Promise.iter (fun res ->
match res with
| Ok () ->
console.info("Success.")
setAdding false
setSuccess true
setNewView None
setNewExec None
let user = Groups.Utils.fgaMember group
let object = sprintf "archive:%O" archiveId
newPermissions
|> Array.map (OpenFGA.Types.ArchiveRelation.toTuple user object)
|> Array.append tuples
|> onUpdate
| Error msg ->
console.error("Error adding permissions %s.", msg)
setLoading false
)
),
[| newPermissions |]
)
let handleCancelClick (ev: Types.Event) =
setAdding false
let handleUpdateView (updated) =
setNewView (Some updated)
let handleUpdateExec (updated) =
setNewExec (Some updated)
let handlePermissionAdd (newPermission: Permission) =
console.debug("Added new permission: %o", newPermission)
onAdd newPermission
React.fragment [
Html.div [
@@ -200,18 +474,13 @@ module GroupArchive =
Html.p "Loading ..."
else
if adding then
Html.button [
prop.onClick handleSaveClick
prop.text "Save"
]
Html.button [
prop.onClick handleCancelClick
prop.text "Cancel"
]
else
Html.button [
prop.disabled (hasViewTerm && hasExecTicket)
prop.disabled (Array.isEmpty availablePermissions)
prop.onClick handleAddClick
prop.text "Add"
]
@@ -229,46 +498,11 @@ module GroupArchive =
"gap-32"
]
prop.children [
if not hasViewTerm then
match newView with
| Some view ->
Html.div [
prop.classes [
"flex-column"
"gap-8"
"shadow"
"brad-8"
"m-8"
"p-16"
]
prop.children [
Html.b "View"
Groups.ViewForm (view, handleUpdateView)
]
]
| None -> ()
if not hasExecTicket then
match newExec with
| Some exec ->
Html.div [
prop.classes [
"flex-column"
"gap-8"
"shadow"
"brad-8"
"m-8"
"p-16"
]
prop.style [
style.flexBasis (length.px 512)
]
prop.children [
Html.b "Exec"
Groups.ExecForm (exec, handleUpdateExec)
]
]
| None -> ()
availablePermissions
|> Array.map (fun permission ->
PermissionCreateCard group archiveId handlePermissionAdd permission
)
|> unbox
]
]
]
@@ -277,15 +511,24 @@ module GroupArchive =
let View (group: string) (archiveId: System.Guid) =
let loading, setLoading = React.useState true
let error, setError = React.useState<string option> None
let archiveOpt, setArchive =
React.useState<Archmaester.Dto.ArchiveProps option> None
let archiveOpt, setArchive = React.useState<Archmaester.Dto.ArchiveProps option> None
let fgaUser = Groups.Utils.fgaMember group
let tuples = OpenFGA.useReadTuples (fgaUser, object = sprintf "archive:%O" archiveId)
let relations: Remoting.ArchiveRelation array =
let permissions: Permission array =
tuples.Tuples
|> Array.choose (fun tuple -> tuple.Condition |> Option.bind OpenFGA.Types.ArchiveRelation.tryOfCondition)
|> Array.choose (fun tuple ->
tuple.Condition
|> Option.bind (fun cond ->
cond
|> OpenFGA.Types.ArchiveRelation.tryOfCondition
|> Option.map (fun rel -> {
Tuple = tuple
Relation = rel
})
)
)
let handlePermissionDelete (tuple: Remoting.Tuple) =
console.debug("Deleting %o from %o", tuple, tuples)
@@ -293,6 +536,29 @@ module GroupArchive =
|> Array.filter (fun existing -> existing.Relation <> tuple.Relation)
|> tuples.SetTuples
let handlePermissionUpdate (updated: Permission) =
console.debug("Updated permission tuple %o", updated)
putPermissions group archiveId [| updated.Relation |]
|> Promise.iter (fun res ->
match res with
| Ok () ->
tuples.Tuples
|> Array.map (fun existing ->
let equal =
existing.Object = updated.Tuple.Object
&& existing.Relation = updated.Tuple.Relation
&& existing.User = updated.Tuple.User
if equal then
updated.Tuple
else
existing
)
|> tuples.SetTuples
| Error msg ->
setError (Some msg)
)
let handleUpdateRelations =
React.useCallback (
(fun (updated: Remoting.Tuple array) ->
@@ -303,6 +569,19 @@ module GroupArchive =
[| box tuples |]
)
let handleAddPermission =
React.useCallback (
(fun (newPermission: Permission) ->
console.debug("New relation added: %o with current: %o", newPermission, tuples.Tuples)
newPermission.Tuple
|> Array.singleton
|> Array.append tuples.Tuples
|> tuples.SetTuples
),
[| box tuples |]
)
React.useEffect (
(fun () ->
setLoading true
@@ -363,7 +642,7 @@ module GroupArchive =
else
Html.div [
prop.children [
PermissionForm group archive.archiveId handleUpdateRelations tuples.Tuples
PermissionForm group archive.archiveId handleAddPermission permissions
]
]
@@ -374,26 +653,23 @@ module GroupArchive =
]
]
if Array.isEmpty relations then
if Array.isEmpty permissions then
Html.p "No permissions"
else
Html.div [
prop.classes [ "flex-row-start"; "gap-32" ]
prop.children (
tuples.Tuples
|> Array.choose (fun tuple ->
tuple.Condition
|> Option.bind (fun cond ->
cond
|> OpenFGA.Types.ArchiveRelation.tryOfCondition
|> Option.map (fun rel ->
match rel with
| Remoting.ArchiveRelation.ViewTerm term ->
ViewTerm handlePermissionDelete term tuple
| Remoting.ArchiveRelation.ExecTicket ticket ->
ExecTicket handlePermissionDelete ticket tuple
)
)
permissions
|> Array.map (fun permission ->
match permission.Relation with
| Remoting.ArchiveRelation.ViewTerm term ->
PermissionCard "View Term" handlePermissionDelete permission.Tuple [|
ViewTerm "view-term-table" handlePermissionUpdate permission term
|]
| Remoting.ArchiveRelation.ExecTicket ticket ->
PermissionCard "Exec Ticket" handlePermissionDelete permission.Tuple [|
ExecTicket "exec-ticket-table" handlePermissionUpdate permission ticket
|]
)
)
]

View File

@@ -575,6 +575,35 @@ module Admin =
return Error (sprintf "Error setting user permissions: %s" e.Message)
}
let updateGroupPermissions (ctx: HttpContext) (req: Remoting.AddGroupPermissionsRequest) =
async {
let user = ctx.User.Identity.Name
let logger = ctx.GetLogger<Remoting.Api.Admin> ()
do logger.LogInformation ("updateGroupPermissions from {User}: {@Req}", user, req)
try
let fga = ctx.GetService<OpenFgaClient> ()
let deletes =
req.Permissions
|> Array.map (permissionToTuple req.ArchiveId req.Group)
|> OpenFGA.Queries.deleteTuples'
let! deleteResp = fga.DeleteTuples deletes |> Async.AwaitTask
do logger.LogInformation ("OpenFGA delete responded with: {JSON}", deleteResp.ToJson ())
let writes =
req.Permissions
|> Array.map (permissionToTuple req.ArchiveId req.Group)
|> ResizeArray
let! writeResp = fga.WriteTuples writes |> Async.AwaitTask
do logger.LogInformation ("OpenFGA write responded with: {JSON}", writeResp.ToJson ())
return Ok ()
with e ->
do logger.LogError (e, "Error updating group permissions")
// TODO: Maybe do not send exn message
return Error (sprintf "Error updating group permissions: %s" e.Message)
}
let private impl (ctx: HttpContext) : Remoting.Api.Admin = {
addArchive = Handler.addArchive ctx
addArchiveGroups = Handler.addArchiveGroups ctx
@@ -593,6 +622,7 @@ module Admin =
removeUsers = Handler.removeUsers ctx
setUserPermissions = Handler.setUserPermissions ctx
updateArchive = Handler.updateArchive ctx
updateGroupPermissions = Handler.updateGroupPermissions ctx
}
let endpoints: HttpHandler =

View File

@@ -126,6 +126,11 @@ module Remoting =
type ArchiveRelation =
| ViewTerm of ViewTerm
| ExecTicket of ExecTicket
with
static member ConditionName (permission: ArchiveRelation) =
match permission with
| ViewTerm _ -> "term"
| ExecTicket _ -> "ticket"
[<Struct>]
type AddArchiveGroupsRequest = {
@@ -230,6 +235,7 @@ module Remoting =
removeUsers: string array -> Async<Result<unit, string>>
setUserPermissions: UserPermissionRequest -> Async<Result<unit, string>>
updateArchive: System.Guid -> EditArchiveRequest -> Async<Result<Archive, string>>
updateGroupPermissions: AddGroupPermissionsRequest -> Async<Result<unit, string>>
}
type OpenFGA = {