Interfaces are an incredibly powerful abstraction for writing extensible and readable code yet developing an intuition for when and how to use them does not always come easily. While there's no substitute for time and practice, the principle of least privilege — a concept most commonly applied to infosec concerns like IAM (Identity and Access Management) — can provide a helpful framework for thinking about the role interfaces play in your code.
The Principle of Least Privilege
When writing a function with an interface rather than a concrete type as a parameter, you are allowing the function to access only the behavior exposed by the interface. The actual object or struct used as an argument may have additional data or functionality but these properties are hidden from the function.
Limiting the visible behavior of a function's parameter is similar to restricting the services and resources the members of a cloud based application can access. For your product you may want an end user to be able to view data but not update it or add additional entries. For that same application you may have a serverless function that needs not only to view the data but to make changes to it as well. And what about deleting content? That may be an action reserved for developers with administrative privileges.
When managing IAM, you could provide all three of these members with administrative level access thus meeting their combined access needs. While this solution enables the developers to conduct their work appropriately it places your data at risk of being illegitimately updated or deleted by end users and the serverless function. This "least common denominator" approach to IAM violates the principle of least privilege.
The principle of least privilege states that a user of a given resource or service should only have the bare minimum permissions required to perform their expected, legitimate tasks.
In the case of a function or method, we might rewrite this principle to state that a function's parameter type should only reveal enough about the type's behavior for the function to perform its expected, legitimate tasks.
Illustrating the Principle of Least Privilege in Code
To illustrate this idea we're going to create interfaces and structs in Go based on resources and roles in GCP (Google Cloud Platform).
We will be building an application that simulates Google Cloud Storage — a service for blob storage that can be divided into logical groupings called buckets and is useful for storing many types of data like images or zip files.
Playing the role of the cloud storage bucket is a Go struct (similar to a class in object-oriented languages). This struct has one property — Blobs
— which is a map of strings as keys and any data type as the values.
// interface{} in Go is an interface that defines no behavior
// and is similar to the `any` type in other languages
type CloudStorageBucket struct {
Blobs map[string]interface{}
}
Next we'll define a function for creating a new instance of CloudStorageBucket
as well as some helper methods for viewing, creating, and deleting blobs.
func NewCloudStorageBucket() *CloudStorageBucket {
return &CloudStorageBucket{
Blobs: make(map[string]interface{}),
}
}
// the variable 'ok' is a boolean value that will return true if
// the key you're trying to access exists for the given map
func (cs CloudStorageBucket) ViewBlob(key string) (interface{}, error) {
if blob, ok := cs.Blobs[key]; ok {
return blob, nil
}
return nil, fmt.Errorf("no blob found with key %s", key)
}
func (cs CloudStorageBucket) CreateBlob(key string, contents interface{}) error {
if _, ok := cs.Blobs[key]; !ok {
cs.Blobs[key] = contents
return nil
}
return fmt.Errorf("blob with key %s already exists", key)
}
func (cs CloudStorageBucket) DeleteBlob(key string) (interface{}, error) {
if blob, ok := cs.Blobs[key]; ok {
delete(cs.Blobs, key)
return blob, nil
}
return nil, fmt.Errorf("no blob found with key %s", key)
}
With that, the basic implementation of a cloud storage bucket is complete.
Defining the Storage Bucket Permissions as Interfaces
Turning our attention to the access permissions on the cloud storage bucket, we'll create three interfaces that expose the behaviors available to the users of this service — viewing, creating, and deleting.
type CloudStorageViewer interface {
ViewBlob(cs *CloudStorageBucket, key string) (interface{}, error)
}
type CloudStorageCreator interface {
CreateBlob(cs *CloudStorageBucket, key string, contents interface{}) error
}
type CloudStorageDeleter interface {
DeleteBlob(cs *CloudStorageBucket, key string) (interface{}, error)
}
Each interface defines only one method because it is focused on exposing only one behavior. They can also be composed together when more complex use cases emerge.
// creating an admin interface by composing together three
// single method interfaces
type CloudStorageAdmin interface {
CloudStorageViewer
CloudStorageCreator
CloudStorageDeleter
}
Creating the Storage Bucket Users
To see this in action, we will implement these interfaces on three different types of users: an end user who should only be able to view the data in cloud storage, a serverless cloud function that should be able to view existing blobs and create new ones, and a developer that has admin access and can perform all three actions.
// Go does not have explicit interface implementation. Any struct
// with a method signature identical to the one defined on an
// interface is treated as an implicit implementation. In that
// respect it is a bit like duck typing in languages like Ruby.
type EndUser struct {}
func (EndUser) ViewBlob(cs *CloudStorageBucket, key string) (interface{}, error) {
return cs.ViewBlob(key)
}
type CloudFunction struct {}
func (CloudFunction) ViewBlob(cs *CloudStorageBucket, key string) (interface{}, error) {
return cs.ViewBlob(key)
}
func (CloudFunction) CreateBlob(cs *CloudStorageBucket, key string, contents interface{}) error {
return cs.CreateBlob(key, contents)
}
type Developer struct {}
func (Developer) ViewBlob(cs *CloudStorageBucket, key string) (interface{}, error) {
return cs.ViewBlob(key)
}
func (Developer) CreateBlob(cs *CloudStorageBucket, key string, contents interface{}) error {
return cs.CreateBlob(key, contents)
}
func (Developer) DeleteBlob(cs *CloudStorageBucket, key string) (interface{}, error) {
return cs.DeleteBlob(key)
}
Applying the Principle of Least Privilege to Function Design
With the interfaces defined and implemented for the three types of users, we can now create functions that use these interfaces as parameter types, beginning with the function ViewCloudStorageBlob
which will accept any type that implements the ViewBlob
method of the CloudStorageViewer
interface, restricting its ability to do more than view blobs in our cloud storage struct.
func ViewCloudStorageBlob(cs *CloudStorageBucket, viewer CloudStorageViewer) {
contents, err := viewer.ViewBlob(cs, "foo")
if err != nil {
fmt.Println(err)
return
}
fmt.Printf("key foo contains %v", contents)
}
func main() {
storage := NewCloudStorageBucket()
endUser := EndUser{}
cloudFunction := CloudFunction{}
developer := Developer{}
ViewCloudStorageBlob(storage, endUser)
ViewCloudStorageBlob(storage, cloudFunction)
ViewCloudStorageBlob(storage, developer)
}
Because all three of our user structs implement the ViewBlob
method, they are all acceptable candidates for the second argument of ViewCloudStorageBlob
. This function does not need to know about creating or deleting blobs, nor does it need to know additional details about the users that implement CloudStorageViewer
. The only thing ViewCloudStorageBlob
needs to know is that ViewBlob
can be invoked. That's it. By creating small modular interfaces we provide single-purpose functions with only the information required for them to satisfy that purpose.
The same steps can be applied to a function that accepts a CloudStorageCreator
type.
func CreateCloudStorageBlob(cs *CloudStorageBucket, creator CloudStorageCreator) {
err := creator.CreateBlob(cs, "foo", []int{1,2,3})
if err != nil {
fmt.Println(err)
return
}
fmt.Println("new blob with key 'foo' successfully created")
}
func main() {
storage := NewCloudStorageBucket()
endUser := EndUser{}
cloudFunction := CloudFunction{}
developer := Developer{}
CreateCloudStorageBlob(storage, cloudFunction)
CreateCloudStorageBlob(storage, developer)
}
This time our function can only be called with two of the three user types as arguments. And even though both CloudFunction
and Developer
still have the ability to view a storage blob, CreateCloudStorageBlob
has no knowledge of this behavior because it does not need to know about it.
If we try to use the EndUser
struct as a CloudStorageCreator
type, we get this message when building the project:
cannot use endUser (type EndUser) as type CloudStorageCreator in argument to CreateCloudStorageBlob:
EndUser does not implement CloudStorageCreator (missing CreateBlob method)
EndUser
does not satisfy the requirements of a CloudStorageCreator
type and therefore we get a compile time error message letting us know that the function is trying to invoke behavior on a type that does not have access to the CreateBlob
action.
Lastly, let's write a function that requires a type with full access, one that uses the CloudStorageAdmin
interface as a parameter type. First we'll create a function for deleting cloud storage blobs, similar to the view and create functions we've already made.
func DeleteCloudStorageBlob(cs *CloudStorageBucket, deleter CloudStorageDeleter) {
contents, err := deleter.DeleteBlob(cs, "foo")
if err != nil {
fmt.Println(err)
return
}
fmt.Printf("successfully deleted %v", contents)
}
Before we write a function that requires the CloudStorageAdmin
type, let's confirm something about the developer struct, that it works as an argument for all three of the other functions that were made for viewing, creating, and deleting.
func main() {
storage := NewCloudStorageBucket()
developer := Developer{}
ViewCloudStorageBlob(storage, developer)
CreateCloudStorageBlob(storage, developer)
DeleteCloudStorageBlob(storage, developer)
}
Because the Developer
struct satisfies the CloudStorageViewer
, CloudStorageCreator
and CloudStorageDeleter
interfaces, it works as an argument for the three functions that accept those types.
If it works as all three types individually, it should also work as an argument for a function that explicitly takes a CloudStorageAdmin
type.
func PerformAdminActions(cs *CloudStorageBucket, admin CloudStorageAdmin) {
ViewCloudStorageBlob(cs, admin)
CreateCloudStorageBlob(cs, admin)
DeleteCloudStorageBlob(cs, admin)
}
func main() {
storage := NewCloudStorageBucket()
developer := Developer{}
PerformAdminActions(storage, developer)
}
The previous functions required very little information about the concrete types they were accepting as arguments and the small, one method interfaces enabled us to hide any additional behavior. Now that we have a function that requires the ability to perform multiple actions against the cloud storage struct, we can leverage those small, one method interfaces in a different way, by composing them together into a new interface.
The end result is that our Developer
struct is useable by functions that require very little information, as with the ViewCloudStorageBlob
function, as well as by functions that require a lot of information like PerformAdminActions
. In either case only the required behaviors are exposed to the function by its parameter type even though the underlying struct is the same.
The Benefits of Understanding and Applying the Principle of Least Privilege
The benefits of applying the principle of least privilege to interface design extend to more than just code written to mimic a cloud service. The same thought process can be applied to the design of any application, making your code more extensible and your intent clearer.
By considering what a function should be permitted to know about its parameters — what those parameters can do rather than what they are — your functions will benefit from greater reuse. Just like predefined roles in cloud IAM make it easier to grant new users a set of related permissions, when an interface is used as a function's parameter type, new concrete types that implement the interface can also be used as an argument without having to change the function itself.
Interfaces designed and named with exposed behavior in mind also communicate intent. In the same way that the GCP IAM role cloudfunctions.invoker communicates what actions a user assigned that role can perform, so too do interfaces like io.Reader or io.Writer communicate the intent of the functions that use them as parameter types. By communicating intent through interfaces, you are letting other developers and your future self know what behaviors a function is and is not expected to invoke on its parameters.
In addition to the benefits to your code, understanding and applying the principle of least privilege will also benefit you as a developer by providing a tangible way of thinking about the role of interfaces, helping you spot opportunities to refactor your code to make use of them. When your ability to spot legitimate use cases for interfaces improves, so too will the readability and extensibility of your code.
Top comments (0)