Bugzilla – Attachment 74365 Details for
Bug 20402
OAuth2 client credentials grant for REST API
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 20402: Implement OAuth2 authentication for REST API
Bug-20402-Implement-OAuth2-authentication-for-REST.patch (text/plain), 21.25 KB, created by
Julian Maurice
on 2018-04-17 17:11:16 UTC
(
hide
)
Description:
Bug 20402: Implement OAuth2 authentication for REST API
Filename:
MIME Type:
Creator:
Julian Maurice
Created:
2018-04-17 17:11:16 UTC
Size:
21.25 KB
patch
obsolete
>From fbd2c36d7d4eb91623ce09aa0b7ec2ea1899fb0f Mon Sep 17 00:00:00 2001 >From: Julian Maurice <julian.maurice@biblibre.com> >Date: Tue, 13 Mar 2018 13:17:12 +0100 >Subject: [PATCH] Bug 20402: Implement OAuth2 authentication for REST API > >It implements only the "client credentials" flow with no scopes >support. API clients are tied to an existing patron and have the same >permissions as the patron they are tied to. >API Clients are defined in $KOHA_CONF. > >Test plan: >0. Install Net::OAuth2::AuthorizationServer 0.16 >1. In $KOHA_CONF, add an <api_client> element under <config>: > <api_client> > <client_id>$CLIENT_ID</client_id> > <client_secret>$CLIENT_SECRET</client_secret> > <patron_id>X</patron_id> <!-- X is an existing borrowernumber --> > </api_client> >2. Apply patch, run updatedatabase.pl and reload starman >3. Install Firefox extension RESTer [1] >4. In RESTer, go to "Authorization" tab and create a new OAuth2 > configuration: > - OAuth flow: Client credentials > - Access Token Request Method: POST > - Access Token Request Endpoint: http://$KOHA_URL/api/v1/oauth/token > - Access Token Request Client Authentication: Credentials in request > body > - Client ID: $CLIENT_ID > - Client Secret: $CLIENT_SECRET >5. Click on the newly created configuration to generate a new token > (which will be valid only for an hour) >6. In RESTer, set HTTP method to GET and url to > http://$KOHA_URL/api/v1/patrons then click on SEND > If patron X has permission 'borrowers', it should return 200 OK > with the list of patrons > Otherwise it should return 403 with the list of required permissions > (Please test both cases) >7. Wait an hour (or run the following SQL query: > UPDATE oauth_access_tokens SET expires = 0) and repeat step 6. > You should have a 403 Forbidden status, and the token must have been > removed from the database. >8. Create a bunch of tokens using RESTer, make some of them expires > using the previous SQL query, and run the following command: > misc/cronjobs/cleanup_database.pl --oauth-tokens > Verify that expired tokens were removed, and that the others are > still there >9. prove t/db_dependent/api/v1/oauth.t > >[1] https://addons.mozilla.org/en-US/firefox/addon/rester/ > >Signed-off-by: Josef Moravec <josef.moravec@gmail.com> >--- > C4/Installer/PerlDependencies.pm | 10 ++ > Koha/OAuth.pm | 69 ++++++++++++++ > Koha/OAuthAccessToken.pm | 11 +++ > Koha/OAuthAccessTokens.pm | 15 +++ > Koha/REST/V1.pm | 3 + > Koha/REST/V1/Auth.pm | 27 ++++++ > Koha/REST/V1/OAuth.pm | 62 +++++++++++++ > Koha/Schema/Result/OauthAccessToken.pm | 72 +++++++++++++++ > api/v1/swagger/paths.json | 3 + > api/v1/swagger/paths/oauth.json | 64 +++++++++++++ > etc/koha-conf.xml | 15 +++ > .../data/mysql/atomicupdate/oauth_tokens.perl | 15 +++ > misc/cronjobs/cleanup_database.pl | 11 +++ > t/db_dependent/api/v1/oauth.t | 101 +++++++++++++++++++++ > 14 files changed, 478 insertions(+) > create mode 100644 Koha/OAuth.pm > create mode 100644 Koha/OAuthAccessToken.pm > create mode 100644 Koha/OAuthAccessTokens.pm > create mode 100644 Koha/REST/V1/OAuth.pm > create mode 100644 Koha/Schema/Result/OauthAccessToken.pm > create mode 100644 api/v1/swagger/paths/oauth.json > create mode 100644 installer/data/mysql/atomicupdate/oauth_tokens.perl > create mode 100755 t/db_dependent/api/v1/oauth.t > >diff --git a/C4/Installer/PerlDependencies.pm b/C4/Installer/PerlDependencies.pm >index 9913d77d8c..84a02b4940 100644 >--- a/C4/Installer/PerlDependencies.pm >+++ b/C4/Installer/PerlDependencies.pm >@@ -888,6 +888,16 @@ our $PERL_DEPS = { > required => 0, > min_ver => '0.52', > }, >+ 'Net::OAuth2::AuthorizationServer' => { >+ usage => 'REST API', >+ required => '1', >+ min_ver => '0.16', >+ }, >+ 'Mojolicious::Plugin::OAuth2::Server' => { >+ usage => 'REST API', >+ required => '1', >+ min_ver => '0.40', >+ } > }; > > 1; >diff --git a/Koha/OAuth.pm b/Koha/OAuth.pm >new file mode 100644 >index 0000000000..6966570502 >--- /dev/null >+++ b/Koha/OAuth.pm >@@ -0,0 +1,69 @@ >+package Koha::OAuth; >+ >+use Modern::Perl; >+use Koha::OAuthAccessTokens; >+use Koha::OAuthAccessToken; >+ >+sub config { >+ return { >+ verify_client_cb => \&_verify_client_cb, >+ store_access_token_cb => \&_store_access_token_cb, >+ verify_access_token_cb => \&_verify_access_token_cb >+ }; >+} >+ >+sub _verify_client_cb { >+ my (%args) = @_; >+ >+ my ($client_id, $client_secret) >+ = @args{ qw/ client_id client_secret / }; >+ >+ return (0, 'unauthorized_client') unless $client_id; >+ >+ my $clients = C4::Context->config('api_client'); >+ $clients = [ $clients ] unless ref $clients eq 'ARRAY'; >+ my ($client) = grep { $_->{client_id} eq $client_id } @$clients; >+ return (0, 'unauthorized_client') unless $client; >+ >+ return (0, 'access_denied') unless $client_secret eq $client->{client_secret}; >+ >+ return (1, undef, []); >+} >+ >+sub _store_access_token_cb { >+ my ( %args ) = @_; >+ >+ my ( $client_id, $access_token, $expires_in ) >+ = @args{ qw/ client_id access_token expires_in / }; >+ >+ my $at = Koha::OAuthAccessToken->new({ >+ access_token => $access_token, >+ expires => time + $expires_in, >+ client_id => $client_id, >+ }); >+ $at->store; >+ >+ return; >+} >+ >+sub _verify_access_token_cb { >+ my (%args) = @_; >+ >+ my $access_token = $args{access_token}; >+ >+ my $at = Koha::OAuthAccessTokens->find($access_token); >+ if ($at) { >+ if ( $at->expires <= time ) { >+ # need to revoke the access token >+ $at->delete; >+ >+ return (0, 'invalid_grant') >+ } >+ >+ return $at->unblessed; >+ } >+ >+ return (0, 'invalid_grant') >+}; >+ >+1; >diff --git a/Koha/OAuthAccessToken.pm b/Koha/OAuthAccessToken.pm >new file mode 100644 >index 0000000000..c322ea645a >--- /dev/null >+++ b/Koha/OAuthAccessToken.pm >@@ -0,0 +1,11 @@ >+package Koha::OAuthAccessToken; >+ >+use Modern::Perl; >+ >+use base qw(Koha::Object); >+ >+sub _type { >+ return 'OauthAccessToken'; >+} >+ >+1; >diff --git a/Koha/OAuthAccessTokens.pm b/Koha/OAuthAccessTokens.pm >new file mode 100644 >index 0000000000..12dbf4ab23 >--- /dev/null >+++ b/Koha/OAuthAccessTokens.pm >@@ -0,0 +1,15 @@ >+package Koha::OAuthAccessTokens; >+ >+use Modern::Perl; >+ >+use base qw(Koha::Objects); >+ >+sub object_class { >+ return 'Koha::OAuthAccessToken'; >+} >+ >+sub _type { >+ return 'OauthAccessToken'; >+} >+ >+1; >diff --git a/Koha/REST/V1.pm b/Koha/REST/V1.pm >index 57ad113291..9059ea5069 100644 >--- a/Koha/REST/V1.pm >+++ b/Koha/REST/V1.pm >@@ -19,6 +19,8 @@ use Modern::Perl; > > use Mojo::Base 'Mojolicious'; > >+use Koha::OAuth; >+ > use C4::Context; > > =head1 NAME >@@ -51,6 +53,7 @@ sub startup { > $self->secrets([$secret_passphrase]); > } > >+ $self->plugin('OAuth2::Server' => Koha::OAuth::config); > $self->plugin(OpenAPI => { > url => $self->home->rel_file("api/v1/swagger/swagger.json"), > route => $self->routes->under('/api/v1')->to('Auth#under'), >diff --git a/Koha/REST/V1/Auth.pm b/Koha/REST/V1/Auth.pm >index c76d26fe36..3ca43faadd 100644 >--- a/Koha/REST/V1/Auth.pm >+++ b/Koha/REST/V1/Auth.pm >@@ -22,10 +22,12 @@ use Modern::Perl; > use Mojo::Base 'Mojolicious::Controller'; > > use C4::Auth qw( check_cookie_auth get_session haspermission ); >+use C4::Context; > > use Koha::Account::Lines; > use Koha::Checkouts; > use Koha::Holds; >+use Koha::OAuth; > use Koha::Old::Checkouts; > use Koha::Patrons; > >@@ -110,6 +112,31 @@ sub authenticate_api_request { > > my $spec = $c->match->endpoint->pattern->defaults->{'openapi.op_spec'}; > my $authorization = $spec->{'x-koha-authorization'}; >+ >+ if (my $oauth = $c->oauth) { >+ my $clients = C4::Context->config('api_client'); >+ $clients = [ $clients ] unless ref $clients eq 'ARRAY'; >+ my ($client) = grep { $_->{client_id} eq $oauth->{client_id} } @$clients; >+ >+ my $patron = Koha::Patrons->find($client->{patron_id}); >+ my $permissions = $authorization->{'permissions'}; >+ # Check if the patron is authorized >+ if ( haspermission($patron->userid, $permissions) >+ or allow_owner($c, $authorization, $patron) >+ or allow_guarantor($c, $authorization, $patron) ) { >+ >+ validate_query_parameters( $c, $spec ); >+ >+ # Everything is ok >+ return 1; >+ } >+ >+ Koha::Exceptions::Authorization::Unauthorized->throw( >+ error => "Authorization failure. Missing required permission(s).", >+ required_permissions => $permissions, >+ ); >+ } >+ > my $cookie = $c->cookie('CGISESSID'); > my ($session, $user); > # Mojo doesn't use %ENV the way CGI apps do >diff --git a/Koha/REST/V1/OAuth.pm b/Koha/REST/V1/OAuth.pm >new file mode 100644 >index 0000000000..7be1a46e27 >--- /dev/null >+++ b/Koha/REST/V1/OAuth.pm >@@ -0,0 +1,62 @@ >+package Koha::REST::V1::OAuth; >+ >+use Modern::Perl; >+ >+use Mojo::Base 'Mojolicious::Controller'; >+ >+use Net::OAuth2::AuthorizationServer; >+use Koha::OAuth; >+ >+use C4::Context; >+ >+sub token { >+ my $c = shift->openapi->valid_input or return; >+ >+ my $grant_type = $c->validation->param('grant_type'); >+ unless ($grant_type eq 'client_credentials') { >+ return $c->render(status => 400, openapi => {error => 'Unimplemented grant type'}); >+ } >+ >+ my $client_id = $c->validation->param('client_id'); >+ my $client_secret = $c->validation->param('client_secret'); >+ >+ my $cb = "${grant_type}_grant"; >+ my $server = Net::OAuth2::AuthorizationServer->new; >+ my $grant = $server->$cb(Koha::OAuth::config); >+ >+ # verify a client against known clients >+ my ( $is_valid, $error ) = $grant->verify_client( >+ client_id => $client_id, >+ client_secret => $client_secret, >+ ); >+ >+ unless ($is_valid) { >+ return $c->render(status => 403, openapi => {error => $error}); >+ } >+ >+ # generate a token >+ my $token = $grant->token( >+ client_id => $client_id, >+ type => 'access', >+ ); >+ >+ # store access token >+ my $expires_in = 3600; >+ $grant->store_access_token( >+ client_id => $client_id, >+ access_token => $token, >+ expires_in => $expires_in, >+ ); >+ >+ my $at = Koha::OAuthAccessTokens->search({ access_token => $token })->next; >+ >+ my $response = { >+ access_token => $token, >+ token_type => 'Bearer', >+ expires_in => $expires_in, >+ }; >+ >+ return $c->render(status => 200, openapi => $response); >+} >+ >+1; >diff --git a/Koha/Schema/Result/OauthAccessToken.pm b/Koha/Schema/Result/OauthAccessToken.pm >new file mode 100644 >index 0000000000..85b8a53fe1 >--- /dev/null >+++ b/Koha/Schema/Result/OauthAccessToken.pm >@@ -0,0 +1,72 @@ >+use utf8; >+package Koha::Schema::Result::OauthAccessToken; >+ >+# Created by DBIx::Class::Schema::Loader >+# DO NOT MODIFY THE FIRST PART OF THIS FILE >+ >+=head1 NAME >+ >+Koha::Schema::Result::OauthAccessToken >+ >+=cut >+ >+use strict; >+use warnings; >+ >+use base 'DBIx::Class::Core'; >+ >+=head1 TABLE: C<oauth_access_tokens> >+ >+=cut >+ >+__PACKAGE__->table("oauth_access_tokens"); >+ >+=head1 ACCESSORS >+ >+=head2 access_token >+ >+ data_type: 'varchar' >+ is_nullable: 0 >+ size: 255 >+ >+=head2 client_id >+ >+ data_type: 'varchar' >+ is_nullable: 0 >+ size: 255 >+ >+=head2 expires >+ >+ data_type: 'integer' >+ is_nullable: 0 >+ >+=cut >+ >+__PACKAGE__->add_columns( >+ "access_token", >+ { data_type => "varchar", is_nullable => 0, size => 255 }, >+ "client_id", >+ { data_type => "varchar", is_nullable => 0, size => 255 }, >+ "expires", >+ { data_type => "integer", is_nullable => 0 }, >+); >+ >+=head1 PRIMARY KEY >+ >+=over 4 >+ >+=item * L</access_token> >+ >+=back >+ >+=cut >+ >+__PACKAGE__->set_primary_key("access_token"); >+ >+ >+# Created by DBIx::Class::Schema::Loader v0.07046 @ 2018-04-11 17:44:30 >+# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:u2e++Jrwln4Qhi3UPx2CQA >+ >+ >+# You can replace this text with custom code or comments, and it will be preserved on regeneration >+1; >diff --git a/api/v1/swagger/paths.json b/api/v1/swagger/paths.json >index f3e843e6c3..6451ec3a37 100644 >--- a/api/v1/swagger/paths.json >+++ b/api/v1/swagger/paths.json >@@ -1,4 +1,7 @@ > { >+ "/oauth/token": { >+ "$ref": "paths/oauth.json#/~1oauth~1token" >+ }, > "/acquisitions/vendors": { > "$ref": "paths/acquisitions_vendors.json#/~1acquisitions~1vendors" > }, >diff --git a/api/v1/swagger/paths/oauth.json b/api/v1/swagger/paths/oauth.json >new file mode 100644 >index 0000000000..4b00db605a >--- /dev/null >+++ b/api/v1/swagger/paths/oauth.json >@@ -0,0 +1,64 @@ >+{ >+ "/oauth/token": { >+ "post": { >+ "x-mojo-to": "OAuth#token", >+ "operationId": "tokenOAuth", >+ "tags": ["oauth"], >+ "produces": [ >+ "application/json" >+ ], >+ "parameters": [ >+ { >+ "name": "grant_type", >+ "in": "formData", >+ "description": "grant type (client_credentials)", >+ "required": true, >+ "type": "string" >+ }, >+ { >+ "name": "client_id", >+ "in": "formData", >+ "description": "client id", >+ "type": "string" >+ }, >+ { >+ "name": "client_secret", >+ "in": "formData", >+ "description": "client secret", >+ "type": "string" >+ } >+ ], >+ "responses": { >+ "200": { >+ "description": "OK", >+ "schema": { >+ "type": "object", >+ "properties": { >+ "access_token": { >+ "type": "string" >+ }, >+ "token_type": { >+ "type": "string" >+ }, >+ "expires_in": { >+ "type": "integer" >+ } >+ } >+ } >+ }, >+ "400": { >+ "description": "Bad Request", >+ "schema": { >+ "$ref": "../definitions.json#/error" >+ } >+ }, >+ "403": { >+ "description": "Access forbidden", >+ "schema": { >+ "$ref": "../definitions.json#/error" >+ } >+ } >+ } >+ } >+ } >+} >diff --git a/etc/koha-conf.xml b/etc/koha-conf.xml >index 5bcd521caf..9cb903c2f5 100644 >--- a/etc/koha-conf.xml >+++ b/etc/koha-conf.xml >@@ -127,6 +127,21 @@ __PAZPAR2_TOGGLE_XML_POST__ > <!-- Secret passphrase used by Mojolicious for signed cookies --> > <api_secret_passphrase>CHANGEME</api_secret_passphrase> > >+ <!-- Uncomment and modify the following to enable OAuth2 authentication for the >+ REST API --> >+ <!-- >+ <api_client> >+ <client_id>client1</client_id> >+ <client_secret>secret1</client_secret> >+ <patron_id>1</patron_id> >+ </api_client> >+ <api_client> >+ <client_id>client2</client_id> >+ <client_secret>secret2</client_secret> >+ <patron_id>2</patron_id> >+ </api_client> >+ --> >+ > <!-- true type font mapping accoding to type from $font_types in C4/Creators/Lib.pm --> > <ttf> > <font type="TR" >__FONT_DIR__/DejaVuSerif.ttf</font> >diff --git a/installer/data/mysql/atomicupdate/oauth_tokens.perl b/installer/data/mysql/atomicupdate/oauth_tokens.perl >new file mode 100644 >index 0000000000..e22df6f9bd >--- /dev/null >+++ b/installer/data/mysql/atomicupdate/oauth_tokens.perl >@@ -0,0 +1,15 @@ >+$DBversion = 'XXX'; >+if (CheckVersion($DBversion)) { >+ $dbh->do(q{DROP TABLE IF EXISTS oauth_access_tokens}); >+ $dbh->do(q{ >+ CREATE TABLE oauth_access_tokens ( >+ access_token VARCHAR(255) NOT NULL, >+ client_id VARCHAR(255) NOT NULL, >+ expires INT NOT NULL, >+ PRIMARY KEY (access_token) >+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8 >+ }); >+ >+ SetVersion( $DBversion ); >+ print "Upgrade to $DBversion done (Bug XXXXX - description)\n"; >+} >diff --git a/misc/cronjobs/cleanup_database.pl b/misc/cronjobs/cleanup_database.pl >index 4514b48b5d..b8f550fb5e 100755 >--- a/misc/cronjobs/cleanup_database.pl >+++ b/misc/cronjobs/cleanup_database.pl >@@ -81,6 +81,7 @@ Usage: $0 [-h|--help] [--sessions] [--sessdays DAYS] [-v|--verbose] [--zebraqueu > --temp-uploads Delete temporary uploads. > --temp-uploads-days DAYS Override the corresponding preference value. > --uploads-missing FLAG Delete upload records for missing files when FLAG is true, count them otherwise >+ --oauth-tokens Delete expired OAuth2 tokens > USAGE > exit $_[0]; > } >@@ -106,6 +107,7 @@ my $special_holidays_days; > my $temp_uploads; > my $temp_uploads_days; > my $uploads_missing; >+my $oauth_tokens; > > GetOptions( > 'h|help' => \$help, >@@ -129,6 +131,7 @@ GetOptions( > 'temp-uploads' => \$temp_uploads, > 'temp-uploads-days:i' => \$temp_uploads_days, > 'uploads-missing:i' => \$uploads_missing, >+ 'oauth-tokens' => \$oauth_tokens, > ) || usage(1); > > # Use default values >@@ -162,6 +165,7 @@ unless ( $sessions > || $special_holidays_days > || $temp_uploads > || defined $uploads_missing >+ || $oauth_tokens > ) { > print "You did not specify any cleanup work for the script to do.\n\n"; > usage(1); >@@ -333,6 +337,13 @@ if( defined $uploads_missing ) { > } > } > >+if ($oauth_tokens) { >+ require Koha::OAuthAccessTokens; >+ >+ my $count = int Koha::OAuthAccessTokens->search({ expires => { '<=', time } })->delete; >+ say "Removed $count expired OAuth2 tokens"; >+} >+ > exit(0); > > sub RemoveOldSessions { >diff --git a/t/db_dependent/api/v1/oauth.t b/t/db_dependent/api/v1/oauth.t >new file mode 100755 >index 0000000000..79d9eceb02 >--- /dev/null >+++ b/t/db_dependent/api/v1/oauth.t >@@ -0,0 +1,101 @@ >+#!/usr/bin/env perl >+ >+# This file is part of Koha. >+# >+# Koha is free software; you can redistribute it and/or modify it under the >+# terms of the GNU General Public License as published by the Free Software >+# Foundation; either version 3 of the License, or (at your option) any later >+# version. >+# >+# Koha is distributed in the hope that it will be useful, but WITHOUT ANY >+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR >+# A PARTICULAR PURPOSE. See the GNU General Public License for more details. >+# >+# You should have received a copy of the GNU General Public License along >+# with Koha; if not, write to the Free Software Foundation, Inc., >+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. >+ >+use Modern::Perl; >+ >+use Test::More tests => 1; >+use Test::Mojo; >+ >+use Koha::Database; >+ >+use t::lib::Mocks; >+use t::lib::TestBuilder; >+ >+my $t = Test::Mojo->new('Koha::REST::V1'); >+my $schema = Koha::Database->new->schema; >+my $builder = t::lib::TestBuilder->new(); >+ >+subtest '/oauth/token tests' => sub { >+ plan tests => 19; >+ >+ $schema->storage->txn_begin; >+ >+ my $patron = $builder->build({ >+ source => 'Borrower', >+ value => { >+ surname => 'Test OAuth', >+ flags => 0, >+ }, >+ }); >+ >+ # Missing parameter grant_type >+ $t->post_ok('/api/v1/oauth/token') >+ ->status_is(400); >+ >+ # Wrong grant type >+ $t->post_ok('/api/v1/oauth/token', form => { grant_type => 'password' }) >+ ->status_is(400) >+ ->json_is({error => 'Unimplemented grant type'}); >+ >+ # No client_id/client_secret >+ $t->post_ok('/api/v1/oauth/token', form => { grant_type => 'client_credentials' }) >+ ->status_is(403) >+ ->json_is({error => 'unauthorized_client'}); >+ >+ my ($client_id, $client_secret) = ('client1', 'secr3t'); >+ t::lib::Mocks::mock_config('api_client', { >+ 'client_id' => $client_id, >+ 'client_secret' => $client_secret, >+ patron_id => $patron->{borrowernumber}, >+ }); >+ >+ my $formData = { >+ grant_type => 'client_credentials', >+ client_id => $client_id, >+ client_secret => $client_secret, >+ }; >+ $t->post_ok('/api/v1/oauth/token', form => $formData) >+ ->status_is(200) >+ ->json_is('/expires_in' => 3600) >+ ->json_is('/token_type' => 'Bearer') >+ ->json_has('/access_token'); >+ >+ my $access_token = $t->tx->res->json->{access_token}; >+ >+ # Without access token, it returns 401 >+ $t->get_ok('/api/v1/patrons')->status_is(401); >+ >+ # With access token, but without permissions, it returns 403 >+ my $tx = $t->ua->build_tx(GET => '/api/v1/patrons'); >+ $tx->req->headers->authorization("Bearer $access_token"); >+ $t->request_ok($tx)->status_is(403); >+ >+ # With access token and permissions, it returns 200 >+ $builder->build({ >+ source => 'UserPermission', >+ value => { >+ borrowernumber => $patron->{borrowernumber}, >+ module_bit => 4, # borrowers >+ code => 'edit_borrowers', >+ }, >+ }); >+ $tx = $t->ua->build_tx(GET => '/api/v1/patrons'); >+ $tx->req->headers->authorization("Bearer $access_token"); >+ $t->request_ok($tx)->status_is(200); >+ >+ $schema->storage->txn_rollback; >+}; >-- >2.14.2
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 20402
:
72865
|
72874
|
72886
|
72887
|
72888
|
72892
|
72893
|
72894
|
73086
|
73087
|
74009
|
74018
|
74050
|
74051
|
74069
|
74070
|
74088
|
74365
|
74366
|
74367
|
74368
|
74369
|
74380
|
74381
|
74382
|
74383
|
74384
|
74385
|
74386
|
74600
|
75026
|
75027
|
75028
|
75029
|
75030
|
75031
|
75032
|
75033
|
75189