hyPiRion

Go-stly Access Rights

posted

My previous post on phantom types in Go was all about using them to remove a bit of boilerplate for the request values you attach to contexts. It’s a cute example, but it only removes a couple of lines of code, and it’s only really useful if you attach singletons on contexts. However, there are cases where phantom types can be much more valuable and prevent erroneous usage.

Transactions With Access Rights

One place where there tends to be no type safety at all is at the database integration level: When you write queries as strings in Go, the code will compile regardless of whether your query compiles or not. There are a couple of ways “around” it:

  • Some IDEs have database integration plugins, which can give warnings if a query is malformed
  • Tests can (and should) have access to a test database so that all queries can be tested – both logically and syntactically
  • ORMs usually provide some auto migration tool to ensure consistency between the struct/object and the underlying database table
  • Haskell and friends have a way to typecheck your queries at compile time

This works to verify that the queries aren’t malformed, but it will not check that the database user/role has the proper access rights. Tests can do that, but doing so in a structured manner is usually time-consuming.

Read and Writes

For most apps, it’s sufficient to ensure your program won’t do a write in a read context, e.g. for sanity’s sake in a GET request or when you’re working on a read replica. We can model that relationship like this:

Read TXWrite TX

That is, any write transaction can also do reads.

I think the easiest way to model this in Go is to have two types: one struct for read transactions and one for write transactions. This won’t give us the ability to use the write transaction for a read operation, but as long as the write transaction either wraps or has a function that returns a read transaction, then this isn’t that big of a deal:

type ReadTx struct {
  *sql.Tx
}

type WriteTx struct {
  *ReadTx
}

// in another package
func ReadUser(tx *mydb.ReadTx, uid UserID) (*User, error) {
  // read user
}

func UpdateUser(tx *mydb.WriteTx, uid UserID, changes UserChanges)
       (*User, error) {
  user, err := ReadUser(tx.ReadTx, uid)
  // handle error
  // do query to read user
}

Even though I have the transaction types and the data model/SQL queries in a different package, I still like to embed the transaction. I tend to have some utility functions on ReadTx that is a tiny bit easier to call when it’s embedded.

Heavy Queries

Read queries aren’t all equal. For example, you may want to bulk-insert data from the database into a data warehouse at regular intervals. I’d say those queries belong to the same package – if your table changes, so will the query – but they should never be used whenever a user performs a request.

You can model this new addition in two ways, depending on whether you need to run write queries during the batch operations:

Read TXRead-heavy TXWrite TXRead TXRead-heavy TXWrite TX

Whatever you pick, embedding either a ReadTx or a WriteTx is sufficient. If you need to write to the database, you may feel a bit annoyed at this structure: You’d have to write myBatchTx.WriteTx.ReadTx to grab the read transaction.

If you’re at that point, you should make your life easier by creating helper methods: Add a Read() *ReadTx method on the WriteTx type, then you can do myBatchTx.Read() because WriteTx is embedded in the read-heavy transaction type.

DAGs are not Trees

The previous technique works as long as you have access rights in the shape of a tree. If you end up with an access control model that is a DAG, you’re in a world of pain if you want to achieve that through pure embedding.

Let’s assume we have reads and writes, but also admin queries for an admin-only UI. Those should not be accessible if you call user-facing endpoints, and so we want to model something like this:

Read TXWrite TXAdmin write TXAdmin read TX

If you’re familiar with multiple inheritance, you’ll see that we’re facing the diamond problem. If we were to use embedded structs as before, then not only would we have to initialise two struct field elements, but we also have to resolve the “ambiguous selector” errors the Go compiler will emit if we attempt to call Read().

However, the diamond problem isn’t a thing for interfaces. Interfaces may embed other interfaces, and it doesn’t matter that there are multiple interfaces specifying the same method, as long as we provide the method. So what we can do is define a set of interfaces like so:

type ReadTx interface {
  Tx()       *sql.Tx
  isReadTx()
}

type WriteTx interface {
  ReadTx
  isWriteTx()
}

type AdminReadTx interface {
  ReadTx
  isAdminReadTx()
}

type AdminWriteTx interface {
  WriteTx
  AdminReadTx
  isAdminWriteTx()
}

Here we have some internal methods so that we can ensure nobody else outside the package can implement these interfaces. Then we can provide a single concrete implementation that implements all of these interfaces:

type tx struct {
  tx *sql.Tx
  // + internals
}

func (*tx) isReadTx()       {}
func (*tx) isWriteTx()      {}
func (*tx) isAdminReadTx()  {}
func (*tx) isAdminWriteTx() {}

tx is internal, and we expose it through the interfaces above with NewXXX functions:

func newTx(tx *sql.Tx) *tx {
  return &tx{
    tx: tx,
  }
}

func NewReadTx(tx *sql.Tx) ReadTx {
  return newTx(tx)
}

// etc

The good thing about this implementation is that we don’t have to unwrap the underlying transaction anymore: A WriteTx is a ReadTx and so on, so we can just pass them as they are.

Spectral Disentanglement

So if this works fine, why even bother with phantom types? One argument is that it’s more aesthetically pleasing. You’ll decouple the access control and the transaction type, and as we will see, there’s not that much more code to write.

That’s a neat bonus, but my main reason for splitting it up is that it allows us to transfer the access rights from one type to another.

You may not always need that property, but there are situations where this is beneficial.

Consider our database transaction example: Sometimes our tasks take too long to be done inside a transaction, so we’d prefer to use a connection (Conn) for parts of the system. That connection must be able to make transactions now and then, and of course we want the access control of the connection type to be transferred to the transactions it generates.

So how do we do that? Without phantom types we’d have to implement all those interfaces again for our own Conn type, but with a Conn() method instead of a Tx() method to fetch the underlying type.

However, these interfaces also have to implement methods to produce transactions, and this is where the interface-only implementation starts to get gnarly. You cannot define the same Tx method multiple times:

type ReadConn interface {
  Tx() ReadTx
}

type WriteConn interface {
  ReadConn
  Tx() WriteTx
}

// emits ./mycode.go:nn:n: duplicate method Tx

To implement this, we will have to make a method called ReadTx for read transactions, a method for WriteTx for write transactions, and so on. That in turn means that there’s no way for us to call a generic method like Tx that will return the access rights the connection has.

But we can do that with phantom types. First off, let us define the access rights again:

type AccessRights interface {
  isAccessRights()
}

type ReadAccess interface {
  AccessRights
  isReadAccess()
}

type WriteAccess interface {
  ReadAccess
  isWriteAccess()
}

type AdminReadAccess interface {
  ReadAccess
  isAdminReadAccess()
}

type AdminWriteAccess interface {
  AdminReadAccess
  WriteAccess
  isAdminWriteAccess()
}

We can now define types that depend on access rights in general:

type Conn[T AccessRights] struct {
  conn *sql.Conn
}

type Tx[T AccessRights] struct {
  tx *sql.Tx
}

And with those in place, we can again begin to implement functions that use these access rights:

func (c *Conn[T]) BeginTx(ctx context.Context, opts *sql.TxOptions)
                         (*Tx[T], error) {
  // start tx, wrap in Tx wrapper
}

// in another package:

func ReadUser[RA mydb.ReadAccess]
             (tx *mydb.Tx[RA], uid UserID) (*User, error) {
  // read user
}

func UpdateUser[WA mydb.WriteAccess]
               (tx *mydb.Tx[WA], uid UserID, changes UserChanges)
               (*User, error) {
  user, err := ReadUser(tx, uid)
  // handle error
  // do query to read user
}

Notice that we can just pass tx down without casting or calling any functions, just like with the interface variant.

Summoning the Phantom

The only problem with this implementation is that we cannot have interfaces as concrete generic types. They can only be passed into functions, but we cannot make them out of thin air:

func NewReadConn(conn *sql.Conn) Conn[ReadAccess] {
  return Conn{conn: conn}
}

// cannot use generic type Conn[T AccessRights]
// without instantiation

Even worse, we cannot make a single type and “cast” it to the right access level:

type allAccessRights struct{}

func (allAccessRights) isAccessRights() {}
func (allAccessRights) isReadAccess()   {}
func (allAccessRights) isWriteAccess()  {}

func NewReadConn(conn *sql.Conn) Conn[ReadAccess] {
  return Conn[allAccessRights]{conn: conn}
}

// cannot use Conn[allAccessRights]{…}
// (value of type Conn[allAccessRights]) as Conn[ReadAccess] value
// in return statement

Essentially, we’re back to the struct issue where we have to embed and resolve the diamond problem somehow.

This time, however, it is less of an issue, and is only a “make the compiler happy” task as we don’t actually use any of the methods. The first three versions are straightforward, but the admin write access will complain until we have resolved the duplicate methods:

type AdminWriteAccessImpl struct {
  AdminReadAccessImpl
  WriteAccessImpl
}

var _ AdminWriteAccess = AdminWriteAccessImpl{}

func (AdminWriteAccessImpl) isAdminWriteAccess() {}

// resolve embedding ambiguity to adhere to the interface:
func (AdminWriteAccessImpl) isAccessRights() {}
func (AdminWriteAccessImpl) isReadAccess() {}

At last, we can create the NewXXX functions and finally start using them:

func NewReadConn(conn *sql.Conn) Conn[ReadAccessImpl] {
  return Conn[ReadAccessImpl]{conn: conn}
}

func example() {
  conn := mydb.NewReadConn(nil)
  tx, err := conn.BeginTx(context.Background(), nil)
  if err != nil {
    // handle error
  }
  // properly shut down the tx somehow
  user, err := users.ReadUser(tx, 100)
  if err != nil {
    // handle error
  }
  // do something with user
}

If you don’t like the -Impl suffix, you can call the interfaces for IReadAccess etc. and the structs for ReadAccess. However, my experience has been that you write the interface type much more than the implementation type.

These Types are Not Proofs

I think this implementation is a great way to convey access rights intents, but to clarify, this does not actually check that the code doesn’t do writes to the database. That’s intentional: For example, a GetDocument function may update a “last accessed” timestamp on the document, or update the notification count for that particular user1. Is that a write? Technically yes, but maybe not semantically.

This also means that this won’t be able to verify that the database user/role we’re logged in as has the right privileges to perform the query, though we can encode the access rights with the pattern we just made.

Not Only for Databases

I’ve talked about databases here, but this access control pattern applies more generally. Static access control for any kind of system (file system, object store, external APIs and so on) can be implemented with this technique.

Is it worth it though? If you only have basic CRUD operations it seems rather pointless, especially if they start with Get/Save/Update/Delete… until you start with the admin functions, which should not be touched by usual requests. Or the heavy queries, which shouldn’t either.

While it will prevent you from using the wrong function in the wrong context, I imagine the most important part is the documentation it brings. It’s easy to think the function name is sufficient, but more often than not, you’ll have to read a little bit of the source or the documentation to figure out what it does. For example, something like RecomputeX will certainly recompute X, but if X is a saved object, will that object be updated or not?

When the function signature contains the access rights it needs, it becomes much quicker to deduce that, even if it is also documented in the docstring. The docstring just isn’t as structured, and thus it isn’t as skimmable, as the function signature.

It’s hard to evaluate how beneficial this is in practice. But given the small upfront cost and the tiny addition to the function signatures you have to make, it feels like the benefits don’t have to be that big to see a return on the investment.

  1. Whether this is sensible depends on context. Sometimes this is fine, but you may want the client to verify that it has read the notification with another API call.