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:
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:
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:
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.
-
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. ↩