Skip to content

Commit

Permalink
feat: add individual resource policies (#114)
Browse files Browse the repository at this point in the history
* fix: fix overspec'ing

* fix: add alias for dev testing

* feat: add Policy to authorize CRUD responses

* feat: only display buttons if policy allows

* fix: minor README update

* fix: filter fields if relationship not viewable

* feat: add individual resource policy behaviors

* style: mix format

* docs: add Policies.md guide

* docs: add manifest.ex example

* docs: shorten section titles

Co-authored-by: Jeremy <jeremy@township.agency>
Co-authored-by: Logan <logan@township.agency>
  • Loading branch information
3 people committed Sep 13, 2021
1 parent 1c6e71f commit b660f73
Show file tree
Hide file tree
Showing 40 changed files with 1,009 additions and 167 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ Update a project that uses Teal to point to your local repo:
]
```

In the same projects, `dev` config, configure Teal to use `vue-cli` generated
assets rather then the compiled assets.
In the same project's `dev` config, configure Teal to use `vue-cli` generated
assets rather than the compiled assets.

```elixir
config :ex_teal, compiled_assets: false
```

Run `yarn && yarn serve`
In Teal's `assets/`, host the assets by running `yarn && yarn dev`
5 changes: 5 additions & 0 deletions assets/src/components/DeleteMenu.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template>
<div>
<button
v-if="shouldShowDeleteButton"
class="btn btn-default btn-only-icon bg-danger text-white"
@click="confirmDeleteSelectedResources"
>
Expand Down Expand Up @@ -53,6 +54,10 @@ export default {
return this.allMatchingSelected
? this.allMatchingResourceCount
: this.selectedResources.length;
},
shouldShowDeleteButton () {
return true;
}
},
Expand Down
19 changes: 18 additions & 1 deletion assets/src/components/Index/ResourceTableRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
<div class="flex">
<span class="table-action">
<router-link
v-if="shouldShowViewButton"
:to="{
name: 'detail',
params: {
Expand All @@ -74,6 +75,7 @@
class="table-action"
>
<router-link
v-if="shouldShowUpdateButton"
:to="{
name: 'edit-attached',
params: {
Expand All @@ -94,6 +96,7 @@
class="table-action"
>
<router-link
v-if="shouldShowUpdateButton"
:to="{
name: 'edit',
params: {
Expand All @@ -109,6 +112,7 @@
</span>
<span class="table-action">
<button
v-if="shouldShowDeleteButton"
class="appearance-none cursor-pointer table-action-link danger"
:title="viaManyToMany ? 'Detach' : 'Delete'"
@click.prevent="openDeleteModal"
Expand Down Expand Up @@ -147,9 +151,10 @@
</template>

<script>
import { InteractsWithResourceInformation } from 'ex-teal-js';
import Deleteable from '@/mixins/Deleteable';
export default {
mixins: [ Deleteable ],
mixins: [ Deleteable, InteractsWithResourceInformation ],
props: {
deleteResource: {
type: Function,
Expand Down Expand Up @@ -212,6 +217,18 @@ export default {
computed: {
resourceId () {
return this.resource.id;
},
shouldShowViewButton () {
return this.resourceInformation && this.resourceInformation.can_view_any;
},
shouldShowUpdateButton () {
return this.resourceInformation && this.resource.meta["can_update?"];
},
shouldShowDeleteButton () {
return this.resourceInformation && this.resource.meta["can_delete?"];
}
},
Expand Down
14 changes: 11 additions & 3 deletions assets/src/views/Detail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
</h2>
<div class="ml-auto flex">
<button
v-if="shouldShowDeleteButton"
class="btn btn-default btn-icon btn-danger mr-3"
title="Delete"
@click="openDeleteModal"
Expand All @@ -64,6 +65,7 @@
</portal>

<router-link
v-if="shouldShowUpdateButton"
:to="{ name: 'edit', params: { id: resource.id } }"
data-testid="edit-resource"
dusk="edit-resource-button"
Expand Down Expand Up @@ -140,6 +142,12 @@ export default {
cardsEndpoint () {
return `/api/${this.resourceName}/cards`;
},
shouldShowUpdateButton () {
return this.resourceInformation && this.resource.meta["can_update?"];
},
shouldShowDeleteButton () {
return this.resourceInformation && this.resource.meta["can_delete?"];
}
},
watch: {
Expand Down Expand Up @@ -221,9 +229,9 @@ export default {
return ExTeal.request()
.get(`/api/${this.resourceName}/${this.resourceId}`)
.then(({ data: { panels, fields, id } }) => {
this.panels = panels;
this.resource = { fields, id };
.then(({ data: resource }) => {
this.panels = resource.panels;
this.resource = resource;
this.loading = false;
})
.catch(error => {
Expand Down
11 changes: 9 additions & 2 deletions assets/src/views/Index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
:can-create="!resourceIsFull"
:can-create="canCreateAny"
:relationship-type="relationshipType"
classes="rounded-tr border-none"
/>
Expand Down Expand Up @@ -224,7 +224,7 @@
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
:can-create="!resourceIsFull"
:can-create="canCreateAny"
:relationship-type="relationshipType"
classes="mt-2"
:with-text="true"
Expand Down Expand Up @@ -539,6 +539,13 @@ export default {
return this.viaHasOne && this.resources.length > 0;
},
/**
* Determine if the resource can be created.
*/
canCreateAny () {
return !this.resourceIsFull && this.resourceInformation.can_create_any;
},
/**
* Determine if the current resource listing is via a has-one relationship.
*/
Expand Down
124 changes: 124 additions & 0 deletions guides/Policies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Policies

Policies are a way of controlling access to resources within Teal.

## Default policy

The default policy is `ExTeal.OpenEverywherePolicy`, which has the following implementations:

```elixir
# Resource level permissions
def create_any?(_conn), do: true
def view_any?(_conn), do: true
def update_any?(_conn), do: true
def delete_any?(_conn), do: true

# Resource item level permissions
def view?(_conn, _item), do: true
def update?(_conn, _item), do: true
def delete?(_conn, _item), do: true
```

To change the default policy, create a new policy module e.g:

```elixir
# lib/my_app_web/ex_teal/closed_everywhere_policy.ex

defmodule MyAppWeb.ExTeal.ClosedEverywherePolicy do
use ExTeal.Policy

def create_any?(_conn), do: false
def view_any?(_conn), do: false
def update_any?(_conn), do: false
def delete_any?(_conn), do: false

def view?(_conn, _item), do: false
def update?(_conn, _item), do: false
def delete?(_conn, _item), do: false
end

```

Then set it in your manifest:

```elixir
# lib/my_app_web/ex_teal/manifest.ex

defmodule MyAppWeb.ExTeal.Manifest do
use ExTeal.Manifest

# ...

def default_policy, do: MyAppWeb.ExTeal.ClosedEverywherePolicy

# ...
end

```

## Resource Policies

You can override the policy at the resource level by implementing the `policy/0` callback

```elixir
# lib/my_app_web/ex_teal/resources/thing_resource.ex

defmodule MyAppWeb.ExTeal.ThingResource do
use ExTeal.Resource

alias ExTeal.Fields.{
ID,
Text
}

def policy, do: MyAppWeb.ExTeal.ThingPolicy

# ...

def fields,
do: [
ID.make(:id),
Text.make(:title),
]
end
```

Lets create that resource policy:

```elixir
# lib/my_app_web/ex_teal/thing_policy.ex

defmodule MyAppWeb.ExTeal.ThingPolicy do
use ExTeal.Policy

# Only allow the super_admin role to interact with "Thing"s

def create_any?(conn) do
conn.assigns.current_user.role == :super_admin
end

def view_any?(conn) do
conn.assigns.current_user.role == :super_admin
end

def update_any?(conn) do
conn.assigns.current_user.role == :super_admin
end

def delete_any?(conn) do
conn.assigns.current_user.role == :super_admin
end

def view?(conn, _thing) do
conn.assigns.current_user.role == :super_admin
end

def update?(conn, _thing) do
conn.assigns.current_user.role == :super_admin
end

def delete?(conn, _thing) do
conn.assigns.current_user.role == :super_admin
end
end
```
7 changes: 7 additions & 0 deletions lib/ex_teal.ex
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ defmodule ExTeal do
end
end

def default_policy do
case manifest() do
nil -> ExTeal.OpenEverywherePolicy
module -> module.default_policy()
end
end

@spec resource_for(String.t()) :: {:ok, ExTeal.Resource.t()} | {:error, :not_found}
def resource_for(name) do
case Enum.find(available_resources(), fn x -> x.uri() == name end) do
Expand Down
11 changes: 11 additions & 0 deletions lib/ex_teal/api/error_serializer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ defmodule ExTeal.Api.ErrorSerializer do
Serializer.as_json(conn, body, 422)
end

def handle_error(conn, :not_authorized) do
body =
Jason.encode!(%{
id: "NOT_AUTHORIZED",
title: "Not Authorized",
status: 403
})

Serializer.as_json(conn, body, 403)
end

def handle_error(conn, _reason) do
body =
Jason.encode!(%{
Expand Down
11 changes: 6 additions & 5 deletions lib/ex_teal/api/many_to_many.ex
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ defmodule ExTeal.Api.ManyToMany do
def creation_pivot_fields(conn, resource_uri, field_name) do
case resource_and_field(resource_uri, field_name) do
{:ok, resource, field} ->
updated_field = field.type.apply_options_for(field, struct(resource.model()), :create)
updated_field =
field.type.apply_options_for(field, struct(resource.model()), conn, :create)

pivot_fields =
updated_field.private_options
Expand All @@ -162,7 +163,7 @@ defmodule ExTeal.Api.ManyToMany do
def update_pivot_fields(conn, resource_uri, resource_id, field_name, related_id) do
with {:ok, resource, field} <- resource_and_field(resource_uri, field_name),
schema when not is_nil(schema) <- resource.handle_show(conn, resource_id),
pivot <- field.type.apply_options_for(field, schema, :update),
pivot <- field.type.apply_options_for(field, schema, conn, :update),
{:ok, related_resource} <- ExTeal.resource_for_model(pivot.private_options.rel.related),
related when not is_nil(related) <- related_resource.handle_show(conn, related_id),
{:ok, pivot_fields} <- Map.fetch(pivot.private_options, :pivot_fields),
Expand Down Expand Up @@ -197,7 +198,7 @@ defmodule ExTeal.Api.ManyToMany do
def update_pivot(conn, resource_uri, resource_id, field_name, related_id) do
with {:ok, resource, field} <- resource_and_field(resource_uri, field_name),
schema when not is_nil(schema) <- resource.handle_show(conn, resource_id),
pivot <- field.type.apply_options_for(field, schema, :update),
pivot <- field.type.apply_options_for(field, schema, conn, :update),
{:ok, related_resource} <- ExTeal.resource_for_model(pivot.private_options.rel.related),
related when not is_nil(related) <- related_resource.handle_show(conn, related_id),
{:ok, pivot_fields} <- Map.fetch(pivot.private_options, :pivot_fields),
Expand Down Expand Up @@ -249,7 +250,7 @@ defmodule ExTeal.Api.ManyToMany do
def reorder(conn, resource_uri, resource_id, field_name) do
with {:ok, resource, field} <- resource_and_field(resource_uri, field_name),
schema when not is_nil(schema) <- resource.handle_show(conn, resource_id),
pivot <- field.type.apply_options_for(field, schema, :update),
pivot <- field.type.apply_options_for(field, schema, conn, :update),
{:ok, _related_resource} <- ExTeal.resource_for_model(pivot.private_options.rel.related) do
multi =
conn.params["data"]
Expand Down Expand Up @@ -339,7 +340,7 @@ defmodule ExTeal.Api.ManyToMany do
defp attached(conn, resource_uri, resource_id, field_name) do
with {:ok, resource, field} <- resource_and_field(resource_uri, field_name),
model when not is_nil(model) <- resource.handle_show(conn, resource_id),
%Field{} = updated_field <- field.type.apply_options_for(field, model, :show) do
%Field{} = updated_field <- field.type.apply_options_for(field, model, conn, :show) do
{:ok, resource, model, updated_field}
else
_ -> {:error, :not_found}
Expand Down
Loading

0 comments on commit b660f73

Please sign in to comment.