[Design Question] "Resource" Based Permissions

Hello Braintrust,

Back with another hypothetical. I want to design permissions in the ontology in a “resource-based” way. Is this possible?

The analogous product experience I want to be able to support is Notion/Google Docs:

  1. You can see everything within your group
  2. You can “share” a resource (in my case, a Purchase Order) with a specific user on the platform who is outside your group

The use case here is to share a Purchase Order with a supplier.

In the simplistic case, where the “resource” that I want to share corresponds 1:1 with a single object, I think I can do this by:

  1. Creating a column/property on the object called SharedUsers, which is an array of multipass user IDs.
  2. Updating the Row Level policy on the backing Restricted View to allow access to a row if the viewing user’s ID is in that array
  3. Creating a “Share” action that adds a user to that array

Does that make sense?

There is a more complicated case, though, where a “resource”, as we define it, is multiple objects. For example, I want to share a Purchase Order (PO), but a PO consists of PO Line Items, Tax, Status, etc. To share this overall PO resource properly, I would need to propagate the new user access to all the dependent objects.

What are some ideas you all have for modeling something like this? In another system (like Hasura), you can model permissions across linked objects, but to my knowledge, this is not possible in Foundry.

Hey @brentshulman-silklin,

TLDR:
This is possible if a) you have a separate Object Type for individual PO Line Items, b) you have a configured Link Type between your PO Object Type and the PO Line Items Object Type, and c) you author a TypeScript function that implements the same logic as your standard action.

More Details
The simple case works and I’ve implemented something like this before exactly as you listed out with the RV-backed Object Type and the extra column/property for SharedUsers as part of the GPS policy. In that implementation, I had yet another extra column/property for SharedGroups that has its own Action Type which does the same thing but for Multipass Group IDs.

Re: the more complicated case, if you design your pipeline such that you have one Object Type for a complete Purchase Order (PO) and then another linked Object Type for individual Purchase Order Line Items (PO-LI), then the scenario you outlined can be achieved through a TypeScript function-backed Action Type. Sample code snippet below:

import { Edits, OntologyEditFunction } from "@foundry/functions-api";
import { Objects, PurchaseOrder, PurchaseOrderLineItem } from "@foundry/ontology-api"; 

export class MyFunctions {
    @Edits(PurchaseOrder, PurchaseOrderLineItem)
    @OntologyEditFunction()
    public deepSharePurchaseOrderAndDependents(po: PurchaseOrder, userId: string): void {
        // Array properties are of type ReadOnlyArray, so need to create a copy
        let arrayCopy = [...po.sharedUsers];
        
        // Append the new value to the copied array
        arrayCopy.push(userId);
        
        // Update the property with the modified array
        po.sharedUsers = arrayCopy;

        // Then, search around to dependents aka linked Line Item objects
        const linkedLineItems = po.purchaseOrderLineItems.all();

        // Make same update for each individual Line Item object
        linkedLineItems.forEach((line) => {
            let currentLineArrayCopy = [...line.sharedUsers];
            currentLineArrayCopy.push(userId);
            line.sharedUsers = currentLineArrayCopy;
        });
    }
}

Considerations
You’ll need to modify the above code to use the correct API names, but once you publish your function, you can create a new function-backed Action Type that handles the permission propagation to all dependent objects.

One thing to keep in mind is that TypeScript functions on Foundry have a hard 60s timeout constraint, and the more line items that are linked to the starting PO, the longer the TS function will take to run.

I had a similar function previously that took a base object and created a deep copy (to a new Object Type) of it and all of its associated “line items”. It was fine for cases even with ~400-500 “line item” objects to copy (took ~30-50s to execute), but I would see failures for attempts on base object copies with ~600 “line item” objects.

Hi @joshOntologize - appreciate the thoughtful response!

Where your solution breaks down is when the linked objects themselves change.

For example, let’s continue the PO <1-M> PO-LI analogy with your proposed solution. If after a share has occurred, the PO, conceptually, is updated, and a new PO-LI is added to that PO (this is very common), the items shared previously via the Function approach are now incorrect.

What do you think of this alternative solution:

  1. Create a ResourceShare object with the following properties.
    1. resourceSharePk
    2. sharedObjectRid (ri.main.onto....<uuid>)
    3. sharedObjectPk (po-1234)
    4. sharedObjectType (ex: purchase-order)
    5. userId (<mp-user-id>)
  2. When a “share” to a PO occurs, you create a ResourceShare object
  3. Create a writeback dataset for ResourceShare
  4. Use that writeback dataset in the pipeline that backs the “resources” we want to enable sharing.
    1. Within this pipeline, we maintain the logic for what other objects to distribute the user’s access to (PO-LI, Supplier, etc.)

Some thoughts on the tradeoffs of this approach:

Pros

  1. Solves the “data change” problem I described above
  2. Allows for centralized Share logic that can refactored to adapt to an inevitably changing ontology**
  3. Shares are now easily revokable. (not critical, but a nice property)
  4. Avoids the risk of function timeout (though I think this would not have likely been an issue we encountered)

Cons

  1. Slow - permission updates via share require a writeback sync, pipeline rebuild, and reindex
    1. Can you build this with an incremental job if implemented smartly? That would speed things up slightly, but I bet E2E this would still take ~5 minutes.
  2. …what else am I missing here…

** From a pipeline perspective, you need to translate the sharedObjectPk → pks of the related resources that need to be shared. The output of this is a mapping table of relatedResourcePk <-> userId. Aggregate that by relatedResourcePk, and you will have your userId[] used for the GPS policies once you join that with the dataset backing the object.the objects

Hey @brentshulman-silklin,

Good call out!

The alternative solution would definitely work and I agree with its pros and cons list, especially points 2 and 4 in the pros list where you can refactor the centralized logic as needed and avoid the function timeout issue altogether.

I think the con you mentioned addresses all the main issues with the approach as there is a latency to be expected which would indeed take a few minutes due to the brief delay in a materialization pulling in updates, the relevant schedule detecting the upstream update, the actual pipeline rebuild, and the Ontology layer sync. If there’s no strict SLA (i.e., immediate), this solution is totally viable and you can even hide the ResourceShare Object Type from most users’ view and have it serve its purpose in the background.

If you do have a strict SLA with a smaller acceptable time window, consider the TypeScript approach paired with additional functions and an Automation. When a new PO-LI is detected from the upstream data pipeline, this would be detected as a trigger for an Automation condition (i.e., Object added to Monitored Object Set), and you can set the Effect to a function-backed Action. The function would accept a single new PO-LI object and assuming it has a property that links it to the core PO object, your function can do an initial search-around to the PO object, store the array value of its sharedUsers property, and then copy it to the brand new object. In our experience, Automate also has a bit of latency, but I think it’ll be much faster than ~5 minutes or so.