epi-project / policy-reasoner

Implements the famous policy reasoner, known as `checker` in Brane terminology. Builds on top of reasoners like eFLINT and meant to be queried by Brane.
1 stars 1 forks source link

Separating out policy reasoner and policy store #42

Open Lut99 opened 1 month ago

Lut99 commented 1 month ago

Based on Daniel's comments made in #39, it's become apparent that maybe a core assumption of the policy-reasoner framework may not hold in all cases: every reasoner has a configuration (e.g., policy) that is sensitive and can be (infrequently) changed.

The current design of the framework has assumed that the framework is responsible for upholding this link. But this makes working with reasoners for which the assumption does not hold tedious; the framework is enforcing things that are unnecessary (mainly "there must always be a policy").

There are two ways to deal with this, in my view:

  1. We change the framework to have this responsibility optionally. It's easier to implement (in fact, Daniel already did so with #40) and it makes the inclusion of this option very dynamic; or
  2. We remove the responsibility from the framework and instead define an intermediate layer to have it instead. I think this is conceptually cleaner, more flexible and more performant as we can define reasoners that simply don't have all this machinery. It will be a major refactor to implement, though.

Even though 1's already merged, I kind of don't like that it weakens the current concept of the framework (in my view) without taking it "all the way". So let's see if we can make 2 happen?

In addition, I have been thinking about this from the other way around. In the AMdEX-architecture (https://zenodo.org/records/10565916), the policy store and policy reasoner are explicitly separated out into separate components (even potentially developed by separate consortium members, as happens in the DMI project). It's probably possible to integrate this in the existing reasoner by defining a special kind of PolicyDataAccess connector that retrieves policy from the external one. But I suspect a lot of use-case specific configuration is required, and again, part of the responsibility the reasoner framework currently has will be taken over by external components (i.e., our assumption that the framework is responsible for this is false). I think the re-phrasing as a third layer that deals with the policy store in various ways may be a much cleaner solution.

But there is a trade-off here in terms of effort. Will it be worth it to work on this?

Concrete tasks:

DanielVoogsgerd commented 1 month ago

I would lean towards option 1 as I think adding another layer would not provide much value. I think the option type is well suited for this use case, as Rust enforced you to deal with the fact that there might be nothing there if you want to use the policy.

Furthermore, I was not involved in the design process of policy reasoner, so I am curious if the idea is that policies are deterministic, i.e. one policy should provide the same verdict if given the same inputs at different times. Because if that is the case, we are in a bit of a pickle if we want to externalize policies at some point. I think AD/LDAP combined with something like the POSIX reasoner comes to mind.

From: https://github.com/epi-project/policy-reasoner/issues/39#issuecomment-2243641557

and we introduce a third layer that sits between the deliberation and the reasoning as a "battery-included" way of adding a policy-store to the story. I.e., we move the store to be a wrapper around arbitrary reasoners. Maybe the fact that we had two APIs to begin with was code smell all along ;)

I think the current design is quite good, and having two APIs here is completely fine. It has a management API for setting the current active policy, for example, and the actual deliberation API which is used for passing along a verdict. I think from the perspective of Brane, adding the third layer makes the interaction with the management API non-standard, which I don't like.

If we really don't like the overhead the current solution has, we can always try to resolve the policy lazily with some future-like type.

All in all, I think we should really focus on getting clear on what we want policies to be in a broader sense now that we have multiple reasoners, I am confident that we can find quite an elegant solution after we have answered that question.

Let me give an example that might help to get clear the boundary conditions of a policy. Is a configuration like a reference to an LDAP host a policy? It is definitely not deterministic, and it does not really by itself contain the content to determine a verdict. However, from a practical perspective, I think this is one of the most realistic values one would insert into their policies as so many companies run AD as the source of truth for ACL.

Lut99 commented 1 month ago

Dammit, you're right. Even that part is being challenged... xD

Well, policy determinism is important insofar auditing is important. In our view, at any point, an auditor must be able to "replay" the check and ensure it was done correctly. So yes, then they'd have to be deterministic.

However, you're right this link isn't always obvious. E.g., in AMdEX, policy is still deterministic but just stored separately. So the same file always yields the same result, but as far as the reasoner is concerned, the config to the policy isn't; the same config (address of the store) yields different results. Or at least, it might.

Bah xD I agree that functionality-wise, the layer doesn't add a lot. But I do think it's conceptually nicer, as the top layer then remains in charge of everything common to reasoners (i.e., the interface with Brane and some basic config support); then the middle layer optionally introduces deterministic, dynamic and sensitive policy (or other config); and then the final layer implements the reasoner using the aforementioned two. But you're able to pull the intermediate layer out, drastically "dumbing-down" the reasoner when it isn't necessary. I feel that in the Option-case a lot of code is still around doing nothing in the case of e.g. NoOp. But maybe I'm seeing this wrong, you've more familiarity with this idea of option at this point :)

DanielVoogsgerd commented 1 month ago

Bah xD I agree that functionality-wise, the layer doesn't add a lot. But I do think it's conceptually nicer, as the top layer then remains in charge of everything common to reasoners (i.e., the interface with Brane and some basic config support); then the middle layer optionally introduces deterministic, dynamic and sensitive policy (or other config); and then the final layer implements the reasoner using the aforementioned two. But you're able to pull the intermediate layer out, drastically "dumbing-down" the reasoner when it isn't necessary. I feel that in the Option-case a lot of code is still around doing nothing in the case of e.g. NoOp. But maybe I'm seeing this wrong, you've more familiarity with this idea of option at this point :)

I am uncertain what you are precisely getting at with this point, but I think we are thinking the same thing here. I do see value in an intermediate layer now. Actually, I think that without it, the policy reasoner is inherently very limited in usage for reasoners with external sources, which I think might be the most realistic usage. So I think we should maybe introduce an intermediate layer that prefetches information which it passes on to reasoner-connectors, which is basically the same as a policy, but could be very dynamic in nature. Using this information, the reasoner-connector can create a deterministic verdict which could be audited.

As an example, for the POSIX-reasoner, the intermediate layer could prefetch the relevant users and groups from AD. This user and group information can then be sent to the reasoner and could be logged for audit. In case of an audit, we cannot audit the source itself, but we can audit based on what information the verdict was determined.

Lut99 commented 1 month ago

Right. Then I guess we can essentially merge the state resolver and policy connectors, right? Which actually makes sense ~ that layer can decide which part of the state/policy is deterministic and all that.

DanielVoogsgerd commented 1 month ago

I actually would like to split them up into two components that are run sequentially.

Lut99 commented 1 month ago

Can you elaborate a little on the first step? I'm not entirely sure what you envision there :#

DanielVoogsgerd commented 1 month ago

No problem. Okay, daily operations are very dynamic. Let's take the Active Directory of a big hospital as an example here. Employees get hired and fired, they might take a leave, causing them to lose access to some data, they might move jobs internally or externally. Our problems in determinism are caused by making repeated verdicts of that dynamic ACL data. So what I envision is a prefetcher that fetches from that dynamic ACL source and compiles a minimum policy that is needed for the reasoner to make a verdict. This policy can then be stored and audit logged as the policy that caused that verdict. This way our reasoner and verdict are completely deterministic from the knowledge of our policy.

So basically it will take the place of the policy store, but it will create ephemeral policies that are used for that one query only (Okay, maybe we could cache/reuse them with some hashing if that is desired, but this would have both up and downsides).

I hope that helps a bit.

Lut99 commented 1 month ago

Oh I see, yes, having a separate input resolution step and reasoning step are good to have. In a way, this is kind of already what the reasoner does; it takes external input with the StateResolver connector and "internal" input (from the internal store) using the PolicyDataAccess connector. Then the reasoner takes the information in both and compiles it to a one-time spec that is fed into the eFLINT reasoner behind it (because eFLINT has no concept of inputs or arguments to spec, so it has to be compiled anyways). The difference is that these are now two separate steps of collecting inputs which aren't really required to be separate, in my opinion.

I like the way you describe it though, because to me it represents a very minimal picture of what we need to feed the reasoner at runtime: we need some input, freeze it such that it makes the reasoner behave deterministically (i.e., same input == same verdict), log it for replayability and then feed that to the reasoner. And then crucially, what kind this input it doesn't really matter; whether its a policy and external state, or a query to the local file system, or just void are all possible based on what the backend reasoner needs.

So, to get towards something concrete, how about:

  1. We merge the StateResolver and PolicyDataAccess resolvers into an InputResolver (name pending) that handles both. It can be called to resolve to some kind of associated type InputResolver::Input. For eFLINT, this would compile a single, monolithic JSON policy spec that embeds both input and policy; whereas for the NoopResolver, this would be ().
  2. We change the PolicyReasoner to take some abstract input I and then force it to always match the one produced by InputResolver. This will allow us to make a direct coupling between the two, i.e., not all InputResolvers are matchable with all PolicyReasoners (seems to make sense). Then we can remove all eFLINT compilation business from the reasoner itself.
  3. We change the AuditLogger accordingly to log the InputResolver::Inputs (through some trait perhaps). Where it currently logs is probably sufficient, but I'd have to see if any logging in the StateResolver or PolicyDataAccess occurs.
  4. At this point, I think that the framework itself hardly has any dynamic input. Everything is handled through the InputResolver, and probably, any configuration that that would need (e.g., where servers live) can be handled by the resolver itself (maybe it would be nice to polish this NestedCliParser business a little bit to make it easier for connectors to get CLI input or something). Dynamicity is afforded by the questions that are asked through the deliberation interface.
  5. Then we move what was essentially the implementation of StateResolver and PolicyDataAccess into an EFlintSqlBraneResolver that does what they used to do, but now embedding the compilation into a single input. Maybe it can still have the policy API embedded within to change the local store. Would probably have to run at a different port though (not a problem AFAIK).

Then two stretch points:

  1. One day, during planning, we are going to want the reasoner to process hypothetical questions. In this case, users give (part of) the input themselves and get an unsigned verdict in return. I don't think it necessarily changes the story, other than that connectors will be extended to deal with this more sliding scale of input.
  2. In your phrasing, it becomes obvious that if same input == same verdict, we can start caching reasoner responses. This is actually amazing because eFLINT is slow as heck, especially when systems become huge, so a very exciting feature to add!

What do you think?