Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add GraphQL API for managing rooms #399

Merged
merged 140 commits into from
Dec 14, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
140 commits
Select commit Hold shift + click to select a range
98ed136
GraphQL API WIP
robertlong Jul 16, 2020
55b1260
Fix formatting and remove inspect
robertlong Jul 16, 2020
bd512d5
Use a changeset error handling middleware
robertlong Jul 16, 2020
3da617c
Add pagination to public rooms query
robertlong Jul 16, 2020
d396147
Add myRooms and authenticated routes
robertlong Jul 16, 2020
8ea148a
Add favorite rooms query
robertlong Jul 16, 2020
c28f8fb
Add more room and scene fields
robertlong Jul 16, 2020
6e718e1
Use dataloader for batch fetching scenes
robertlong Jul 16, 2020
b269d9b
Fix typo in comment
johnshaughnessy Jul 30, 2020
ada6276
specify preferred json codec
johnshaughnessy Jul 30, 2020
bdc1f8d
Add test cases for room query
johnshaughnessy Jul 30, 2020
125769c
DRY up tests
johnshaughnessy Jul 30, 2020
6883a09
Remove unused "variables" from tests
johnshaughnessy Jul 30, 2020
1a5853f
DRY : add assign_creator function
johnshaughnessy Jul 30, 2020
b585a8c
Rename put_auth_header_for_email
johnshaughnessy Jul 30, 2020
002f1e9
DRY: put_auth_header_for_account
johnshaughnessy Jul 30, 2020
66d8c4a
DRY: graphql query
johnshaughnessy Jul 30, 2020
b7c55e5
Don't need to hit the iql api
johnshaughnessy Jul 30, 2020
96d27c8
Test room creation. Add default creator assignment
johnshaughnessy Jul 30, 2020
6b4c0b6
Specify json_codec
johnshaughnessy Jul 30, 2020
fec3753
Add room name to mutation result
johnshaughnessy Jul 30, 2020
012d040
Test pagination
johnshaughnessy Jul 30, 2020
0e3f85e
Fix warnings
johnshaughnessy Jul 30, 2020
8f2611c
Formatting
johnshaughnessy Jul 30, 2020
e60ddff
Add mutation for updating room name
johnshaughnessy Aug 17, 2020
2f4c8bb
Add capabilities to room resolver
johnshaughnessy Aug 24, 2020
ee0a66c
Refactor for readability
johnshaughnessy Aug 24, 2020
4afe9d6
Broadcast changes to anyone connected to the hub channel
johnshaughnessy Aug 24, 2020
5a81895
Add ability to update member_permissions
johnshaughnessy Aug 24, 2020
81726e7
Remove unused vars
johnshaughnessy Aug 24, 2020
15d6475
Add some documentation for the API
johnshaughnessy Aug 25, 2020
c5e89eb
Fixup doc
johnshaughnessy Aug 25, 2020
5dc75b3
Add ability to modify allow_promotion
johnshaughnessy Aug 25, 2020
57a052f
Add descriptions to graphql objects/fields
johnshaughnessy Aug 26, 2020
20328ed
Add descriptions to scene types
johnshaughnessy Aug 26, 2020
67dd3c0
Match on Repo.update errors
johnshaughnessy Aug 27, 2020
342c165
Make specifying a room name optional. Add other fields
johnshaughnessy Aug 27, 2020
1b06615
Authorization is enforced on a per-resolver basis
johnshaughnessy Aug 28, 2020
568548f
Fix failing test case for allow_promotion
johnshaughnessy Sep 9, 2020
f8f8477
Add helper script for setting Authorization header
johnshaughnessy Sep 9, 2020
dcc8672
Remove duplicate field
johnshaughnessy Sep 9, 2020
634f0f4
Setup guardian_db and ApiTokens
johnshaughnessy Sep 18, 2020
6de2ca2
Show that revoked token cannot be verified in test
johnshaughnessy Sep 18, 2020
8c0e869
Confer permissions onto api tokens
johnshaughnessy Sep 18, 2020
c8ac232
Remove hub_refresh_by_api
johnshaughnessy Sep 28, 2020
3841cbb
Add primitive timing info as middleware
johnshaughnessy Sep 29, 2020
01a6331
Build up middleware incrementally
johnshaughnessy Sep 29, 2020
5a47417
Merge remote-tracking branch 'origin/feature/api-tokens' into feature…
johnshaughnessy Sep 29, 2020
18b4b62
Verify the permissions on graphql api usage
johnshaughnessy Sep 30, 2020
02c76c3
Start handling auth_errors in the plug
johnshaughnessy Sep 30, 2020
028c3ae
Update guardian so we can avoid halt on error
johnshaughnessy Sep 30, 2020
693204d
Add guardian_phoenix after guardian upgrade
johnshaughnessy Sep 30, 2020
1d9fedd
Fix tests. Check for token in middleware.
johnshaughnessy Oct 1, 2020
5f03033
Tighten up error handling / reporting
johnshaughnessy Oct 1, 2020
5aa730c
Add TODO's from talking with Dom
johnshaughnessy Oct 2, 2020
61e097d
Rename return_error -> put_error_result
johnshaughnessy Oct 9, 2020
81c84e8
Rename Context -> AddAbsintheContext
johnshaughnessy Oct 9, 2020
5c6b08c
Rename context.ex -> add_absinthe_context.ex
johnshaughnessy Oct 9, 2020
d9caf59
Remove unused middleware. Rename PutErrorResult
johnshaughnessy Oct 9, 2020
85301be
Add mix task for generating api tokens
johnshaughnessy Oct 10, 2020
edb17af
Remove unused middleware
johnshaughnessy Oct 10, 2020
b7eabe2
Implement scopes and app_tokens
johnshaughnessy Oct 13, 2020
6db7fcf
Modify helper mix task for generating tokens
johnshaughnessy Oct 13, 2020
f365bca
Remove insert auth header helper
johnshaughnessy Oct 13, 2020
e699e8b
Minor changes
johnshaughnessy Oct 16, 2020
f96f226
Remove unnecessary middleware
johnshaughnessy Oct 16, 2020
69902c7
Rename Ret.ApiToken -> Ret.Api.Token
johnshaughnessy Oct 16, 2020
f652f0c
Update room access pattern for user and app tokens
johnshaughnessy Oct 19, 2020
9a5c089
Fix tests and warnings
johnshaughnessy Oct 19, 2020
dc45130
Generate random room names
johnshaughnessy Oct 20, 2020
5084f61
Reimplement create and update room with auth
johnshaughnessy Oct 20, 2020
05d8008
Add some notes for graphiql testing
johnshaughnessy Oct 20, 2020
a279ce5
Remove unused middleware
johnshaughnessy Oct 20, 2020
b9a7175
Remove commented code
johnshaughnessy Oct 20, 2020
82108fd
Remove unused permissions
johnshaughnessy Oct 20, 2020
a386b8f
Remove IO.inspect
johnshaughnessy Oct 20, 2020
b876e86
Fix warnings
johnshaughnessy Oct 21, 2020
1de05d7
Implement can? for :reticulum_app_token
johnshaughnessy Oct 21, 2020
e2eec9b
Put generated token onto clipboard
johnshaughnessy Oct 21, 2020
6f5a4e6
Check permissions for getting public rooms
johnshaughnessy Oct 21, 2020
f121458
Remove unused function
johnshaughnessy Oct 21, 2020
2eb4934
Remove unused function
johnshaughnessy Oct 21, 2020
497d97c
Remove outdated tests
johnshaughnessy Oct 21, 2020
550863c
Add comments
johnshaughnessy Oct 22, 2020
7f1c1cb
Lengthen ttl
johnshaughnessy Oct 22, 2020
9f23b27
Create API token module. Replace jwt's in API
johnshaughnessy Oct 28, 2020
dfaf3f8
Remove guardian db
johnshaughnessy Oct 28, 2020
884893b
Remove unused secrets
johnshaughnessy Oct 28, 2020
3040fb4
Remove unused function
johnshaughnessy Oct 28, 2020
8dc209c
Removed unused alias/import
johnshaughnessy Oct 28, 2020
c88e0a0
(Re)Implement revoke for tokens
johnshaughnessy Oct 28, 2020
272003d
Fix introspection queries and invalid token errors
johnshaughnessy Oct 28, 2020
c5096c7
Check for introspection types how Absinthe does
johnshaughnessy Oct 28, 2020
75a2087
Fix warnings
johnshaughnessy Oct 28, 2020
6c22990
Remove TODO
johnshaughnessy Oct 28, 2020
d3bf787
Fix tests
johnshaughnessy Oct 28, 2020
300bf33
Add sample graphiql workspace
johnshaughnessy Oct 28, 2020
a32f840
Format
johnshaughnessy Oct 28, 2020
6f810a4
Update API Guide
johnshaughnessy Oct 28, 2020
8a2e6f4
Remove graphiql notes
johnshaughnessy Oct 28, 2020
737b716
Update scopes table in guide
johnshaughnessy Oct 28, 2020
04ee343
Update formatting in guide
johnshaughnessy Oct 28, 2020
8d0d6df
Update justification in guide table
johnshaughnessy Oct 28, 2020
560da97
Remove unused error message
johnshaughnessy Oct 29, 2020
275cfde
Move dataloader config
johnshaughnessy Oct 29, 2020
617a3bf
Change title of guide
johnshaughnessy Oct 29, 2020
59153bf
Do not assume xclip exists
johnshaughnessy Nov 13, 2020
0df81f4
Check write_rooms scope to allow update_hub
johnshaughnessy Nov 13, 2020
c856764
Remove things from documentation that are not done
johnshaughnessy Nov 13, 2020
aa718ae
Check hub_bindings before allowed embeds
johnshaughnessy Nov 13, 2020
f8919ab
Remove API credentials expiration
johnshaughnessy Nov 17, 2020
a088b2f
Include rooms whose entry mode is invite
johnshaughnessy Nov 17, 2020
8ef4ba1
Return more specific token error: :token_revoked
johnshaughnessy Nov 17, 2020
6d19640
Return scene or scene_listing in room result
johnshaughnessy Nov 17, 2020
e7aa161
Add json scalar type for user_data
johnshaughnessy Nov 17, 2020
6d1999b
Add missing close parenthesis
johnshaughnessy Nov 17, 2020
465f637
Add indexes and prevent null in credentials table schema
johnshaughnessy Nov 17, 2020
6afdb17
Fix query for favorite rooms
johnshaughnessy Nov 17, 2020
0c9638f
Define internal functions with defp
johnshaughnessy Nov 17, 2020
9009d96
Fix call to internal function
johnshaughnessy Nov 17, 2020
4923a66
Remove max_page_size
johnshaughnessy Nov 17, 2020
016d737
Fix credential changeset validation/constraints
johnshaughnessy Nov 17, 2020
832898b
Do not have all tokens end in "09"
johnshaughnessy Nov 17, 2020
50f7df6
Prefix the sid to the rest of the token
johnshaughnessy Nov 17, 2020
ec55da4
Update comment for Can impl for Atom
johnshaughnessy Nov 17, 2020
0eabfb3
Add create_room function for api
johnshaughnessy Nov 18, 2020
4d1d9c8
Fixup scene changes and member permissions
johnshaughnessy Nov 19, 2020
ec9cd68
Fix member perm parsing: return ArgumentError
johnshaughnessy Nov 19, 2020
7059fdd
Remove unused test queries
johnshaughnessy Nov 19, 2020
461de60
Update workspace
johnshaughnessy Dec 7, 2020
4bdde03
PR feedback
johnshaughnessy Dec 14, 2020
70bb8a5
Remove issued_at field
johnshaughnessy Dec 14, 2020
a02026d
Add (regular) API to manage (graphql) credentials
johnshaughnessy Dec 9, 2020
2b633c1
Expand admin account permissions
johnshaughnessy Dec 10, 2020
e448b99
Remove create_accounts scope
johnshaughnessy Dec 14, 2020
80c275d
Create test helper
johnshaughnessy Dec 14, 2020
3b49320
PR Feedback
johnshaughnessy Dec 14, 2020
ca9ab00
Require a server-level flag for graphql api
johnshaughnessy Dec 14, 2020
d3adae7
lint
johnshaughnessy Dec 14, 2020
2de8003
Merge pull request #436 from mozilla/feature/credentials-api
johnshaughnessy Dec 14, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions guides/api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Hubs Server API
Reticulum includes a [GraphQL](https://graphql.org/) API to better allow you to write plugins or customize the app to your needs.

## Accessing the API
The API can be accessed by sending `GET` or `POST` requests to `/api/v2_alpha/` with a valid GraphQL document in the request body. Note: This path is subject to change as we get out of early testing.

Requests can be sent by a variety of standard tools:
- an `HTTP` client library,
- a command line tool like `curl`,
- a GraphQL-specific client library,
- any other tool that speaks `HTTP`.

Reticulum ships with [GraphiQL](https://github.com/graphql/graphiql/tree/main/packages/graphiql#graphiql), a graphical interactive in-browser GraphQL IDE that makes it easy to test and learn the API. It can be accessed by navigating to `<your_hubs_cloud_endpoint>/api/v2_alpha/graphiql`. [This example workspace](../test/api/v2/graphiql-workspace-2020-10-28-15-28-39.json) demonstrates several queries and can be loaded into the GraphiQL interface. You will have to generate and supply your own API access tokens.

## Authentication and Authorization
Most requests require an API Access Token for authentication and authorization.

### API Access Token Types
There are two types of API Access Tokens:
- `:account` tokens act on behalf of a specific user
- `:app` tokens act on behalf of the hubs cloud itself

### Scopes
When generating API Access Tokens, you specify which `scopes` to grant that token. Scopes allow the token to be used to perform specific actions.

| Scope | API Actions |
| --: | --- |
| `read_rooms` | `myRooms`, `favoriteRooms`, `publicRooms` |
| `write_rooms` | `createRoom`, `updateRoom` |

Scopes, actions, and token types are expected to expand over time.

Tokens can be generated on the command line with `mix generate_api_token`. Soon this method will be replaced with a web API and interface.

### Using API Access Tokens

To attach an API Access Token to a request, add the `HTTP` header `Authorization` with value `Bearer: <your API token>`.


78 changes: 78 additions & 0 deletions lib/mix/tasks/generate_api_token.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
defmodule Mix.Tasks.GenerateApiToken do
@moduledoc "Generates an Api Token for the given account email"

use Mix.Task

alias Ret.{Account}
alias Ret.Api.TokenUtils

@impl Mix.Task
def run(_) do
user_or_app =
"Generate user token or app token? [user or app]"
|> Mix.shell().prompt()
|> String.trim()

case user_or_app do
"user" ->
gen_user_token()

"app" ->
gen_app_token()

_ ->
Mix.shell().error("Input not recognized. Type \"user\" or \"app\".")
run([])
end
end

defp gen_user_token() do
email =
"Enter email address of the user whose account will be associated in this token: [foo@bar.com]\n"
|> Mix.shell().prompt()
|> String.trim()

Mix.Task.run("app.start")

case Account.account_for_email(email) do
nil ->
Mix.shell().error("Could not find account for the given email address: #{email}")

account ->
IO.puts("Account found:")

account
|> Inspect.Algebra.to_doc(%Inspect.Opts{})
|> Inspect.Algebra.format(80)
|> IO.puts()

if Mix.shell().yes?("Generate token for this account [#{email}]?") do
gen_token_for_account(account)
end
end
end

defp gen_app_token() do
if Mix.shell().yes?("Are you sure you want to generate an app token?") do
Mix.Task.run("app.start")

case TokenUtils.gen_app_token() do
{:ok, token, _claims} ->
Mix.shell().info("Successfully generated token:\n#{token}")

{:error, reason} ->
Mix.shell().error("Error: #{reason}")
end
end
end

defp gen_token_for_account(account) do
case TokenUtils.gen_token_for_account(account) do
{:ok, token, _claims} ->
Mix.shell().info("Successfully generated token:\n#{token}")

{:error, reason} ->
Mix.shell().error("Error: #{reason}")
end
end
end
8 changes: 8 additions & 0 deletions lib/ret/account.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ defmodule Ret.Account do
timestamps()
end

def query do
from(account in Account)
end

def where_account_id_is(query, id) do
from(account in query, where: account.account_id == ^id)
end

def has_accounts?(), do: from(a in Account, limit: 1) |> Repo.exists?()
def has_admin_accounts?(), do: from(a in Account, limit: 1) |> where(is_admin: true) |> Repo.exists?()
def exists_for_email?(email), do: account_for_email(email) != nil
Expand Down
103 changes: 103 additions & 0 deletions lib/ret/api/can_credentials.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
defimpl Canada.Can, for: Ret.Api.Credentials do
import Canada, only: [can?: 2]
alias Ret.{Account, Hub}
alias Ret.Api.{Credentials, Scopes}

def can?(
%Credentials{is_revoked: true},
_action,
_resource
) do
false
end

def can?(
%Credentials{subject_type: :app, scopes: scopes},
:get_rooms_created_by,
%Account{} = account
) do
Scopes.read_rooms() in scopes and can?(:reticulum_app_token, get_rooms_created_by(account))
end

def can?(
%Credentials{subject_type: :account, account: subject, scopes: scopes},
:get_rooms_created_by,
%Account{} = account
) do
Scopes.read_rooms() in scopes and can?(subject, get_rooms_created_by(account))
end

def can?(
%Credentials{subject_type: :app, scopes: scopes},
:get_favorite_rooms_of,
%Account{} = account
) do
Scopes.read_rooms() in scopes and can?(:reticulum_app_token, get_favorite_rooms_of(account))
end

def can?(
%Credentials{subject_type: :account, account: subject, scopes: scopes},
:get_favorite_rooms_of,
%Account{} = account
) do
Scopes.read_rooms() in scopes and can?(subject, get_favorite_rooms_of(account))
end

def can?(
%Credentials{subject_type: :app, scopes: scopes},
:get_public_rooms,
_
) do
Scopes.read_rooms() in scopes and can?(:reticulum_app_token, get_public_rooms(nil))
end

def can?(
%Credentials{subject_type: :account, account: subject, scopes: scopes},
:get_public_rooms,
_
) do
Scopes.read_rooms() in scopes and can?(subject, get_public_rooms(nil))
end

def can?(
%Credentials{subject_type: :app, scopes: scopes},
:create_room,
_
) do
Scopes.write_rooms() in scopes && can?(:reticulum_app_token, create_hub(nil))
end

def can?(
%Credentials{subject_type: :account, account: subject, scopes: scopes},
:create_room,
_
) do
Scopes.write_rooms() in scopes && can?(subject, create_hub(nil))
end

def can?(%Credentials{subject_type: :app, scopes: scopes}, :embed_hub, %Hub{} = hub) do
Scopes.read_rooms() in scopes && can?(:reticulum_app_token, embed_hub(hub))
end

def can?(%Credentials{subject_type: :account, account: subject, scopes: scopes}, :embed_hub, %Hub{} = hub) do
Scopes.read_rooms() in scopes && can?(subject, embed_hub(hub))
end

def can?(
%Credentials{subject_type: :app, scopes: scopes},
:update_room,
%Hub{} = hub
) do
Scopes.write_rooms() in scopes && can?(:reticulum_app_token, update_hub(hub))
end

def can?(
%Credentials{subject_type: :account, account: subject, scopes: scopes},
:update_room,
%Hub{} = hub
) do
Scopes.write_rooms() in scopes && can?(subject, update_hub(hub))
end

def can?(_, _, _), do: false
end
128 changes: 128 additions & 0 deletions lib/ret/api/credentials.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
defmodule Ret.Api.Credentials do
@moduledoc """
Credentials for API access.
"""
alias Ret.Api.Credentials

alias Ret.Account

use Ecto.Schema
import Ecto.Query, only: [from: 2]
import Ecto.Changeset
alias Ret.Api.{TokenSubjectType, ScopeType}

@schema_prefix "ret0"
@primary_key {:api_credentials_id, :id, autogenerate: true}

schema "api_credentials" do
field(:api_credentials_sid, :string)
field(:token_hash, :string)
field(:subject_type, TokenSubjectType)
field(:is_revoked, :boolean)
field(:scopes, {:array, ScopeType})

belongs_to(:account, Account, references: :account_id)
timestamps()
end

@required_keys [:api_credentials_sid, :token_hash, :subject_type, :is_revoked, :scopes]
@permitted_keys @required_keys

def generate_credentials(%{subject_type: _st, scopes: _sc, account_or_nil: account_or_nil} = params) do
sid = Ret.Sids.generate_sid()

# Use 18 bytes (not 16, the default) to avoid having all tokens end in "09"
# See https://github.com/patricksrobertson/secure_random.ex/issues/11
# Prefix the sid to the rest of the token for ease of management
token = "#{sid}.#{SecureRandom.urlsafe_base64(18)}"

params =
Map.merge(params, %{
api_credentials_sid: sid,
token_hash: Ret.Crypto.hash(token),
is_revoked: false
})

case %Credentials{}
|> change()
|> cast(params, @permitted_keys)
|> maybe_put_assoc_account(account_or_nil)
|> validate_required(@required_keys)
|> validate_change(:subject_type, &validate_field/2)
|> validate_change(:scopes, &validate_field/2)
|> unique_constraint(:api_credentials_sid)
|> unique_constraint(:token_hash)
# TODO: We can pass multiple fields to unique_contraint when we update ecto
# https://github.com/elixir-ecto/ecto/pull/3276
|> Ret.Repo.insert() do
{:ok, credentials} ->
{:ok, token, credentials}

{:error, reason} ->
{:error, reason}
end
end

defp maybe_put_assoc_account(changeset, %Account{} = account) do
put_assoc(changeset, :account, account)
end

defp maybe_put_assoc_account(changeset, nil) do
changeset
end

defp validate_single_scope_type(scope) do
if ScopeType.valid_value?(scope) do
[]
else
[invalid_scope: "Unrecognized scope type. Got #{scope}."]
end
end

def validate_field(:scopes, scopes) do
Enum.reduce(scopes, [], fn scope, errors ->
errors ++ validate_single_scope_type(scope)
end)
end
johnshaughnessy marked this conversation as resolved.
Show resolved Hide resolved

def validate_field(:subject_type, subject_type) do
if TokenSubjectType.valid_value?(subject_type) do
[]
else
[invalid_subject_type: "Unrecognized subject type. Must be app or account. Got #{subject_type}."]
end
end

def revoke(credentials) do
credentials
|> change()
|> put_change(:is_revoked, true)
|> Ret.Repo.update()
end

def query do
from(c in Credentials, left_join: a in Account, on: c.account_id == a.account_id, preload: [account: a])
end

def where_sid_is(query, sid) do
from([credential, _account] in query,
where: credential.api_credentials_sid == ^sid
)
end

def where_token_hash_is(query, hash) do
from([credential, _account] in query,
where: credential.token_hash == ^hash
)
end

def where_account_is(query, %Account{account_id: id}) do
from([credential, _account] in query,
where: credential.account_id == ^id
)
end

def app_token_query() do
from(c in Credentials, where: c.subject_type == ^:app)
end
end
11 changes: 11 additions & 0 deletions lib/ret/api/dataloader.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
defmodule Ret.Api.Dataloader do
@moduledoc "Configuration for dataloader"

import Ecto.Query
alias Ret.{Repo, Scene, SceneListing}

def source(), do: Dataloader.Ecto.new(Repo, query: &query/2)
# Guard against loading removed scenes or delisted scene listings
def query(Scene, _), do: from(s in Scene, where: s.state != ^:removed)
def query(SceneListing, _), do: from(sl in SceneListing, where: sl.state != ^:delisted)
end
Loading