#975 RBAC for port control with new portctl_by_role setting

This commit is contained in:
Oliver Gorwits
2023-06-27 22:52:04 +01:00
parent 8d85562396
commit 2cbb68889a
19 changed files with 139 additions and 41 deletions

View File

@@ -11,7 +11,7 @@ __PACKAGE__->load_namespaces(
); );
our # try to hide from kwalitee our # try to hide from kwalitee
$VERSION = 79; # schema version used for upgrades, keep as integer $VERSION = 80; # schema version used for upgrades, keep as integer
use Path::Class; use Path::Class;
use File::ShareDir 'dist_dir'; use File::ShareDir 'dist_dir';

View File

@@ -27,6 +27,8 @@ __PACKAGE__->add_columns(
{ data_type => "timestamp", is_nullable => 1 }, { data_type => "timestamp", is_nullable => 1 },
"port_control", "port_control",
{ data_type => "boolean", default_value => \"false", is_nullable => 1 }, { data_type => "boolean", default_value => \"false", is_nullable => 1 },
"portctl_role",
{ data_type => "text", is_nullable => 1 },
"ldap", "ldap",
{ data_type => "boolean", default_value => \"false", is_nullable => 1 }, { data_type => "boolean", default_value => \"false", is_nullable => 1 },
"radius", "radius",

View File

@@ -63,7 +63,7 @@ sub vlan_reconfig_check {
return; return;
} }
=head2 port_reconfig_check( $port ) =head2 port_reconfig_check( $port, $device?, $user? )
=over 4 =over 4
@@ -90,14 +90,19 @@ C<$port> has a phone connected.
Permission check that C<portctl_vlans> is true if C<$port> is a vlan Permission check that C<portctl_vlans> is true if C<$port> is a vlan
subinterface. subinterface.
=item *
Permission check on C<portctl_by_role> if the device and user are provided. A
bare username will be promoted to a user instance.
=back =back
Will return nothing if these checks pass OK. Will return false if these checks pass OK.
=cut =cut
sub port_reconfig_check { sub port_reconfig_check {
my $port = shift; my ($port, $device, $user) = @_;
my $ip = $port->ip; my $ip = $port->ip;
my $name = $port->port; my $name = $port->port;
@@ -132,7 +137,50 @@ sub port_reconfig_check {
return "forbidden: [$name] is a vlan interface on [$ip]" return "forbidden: [$name] is a vlan interface on [$ip]"
if $is_vlan and not setting('portctl_vlans'); if $is_vlan and not setting('portctl_vlans');
return; # portctl_by_role check
if ($device and ref $device and $user) {
$user = ref $user ? $user :
schema(vars->{'tenant'})->resultset('User')
->find({ username => $user });
my $username = $user->username;
# special case admin user allowed to continue, because
# they can submit port control jobs
return "forbidden: user [$username] has no right to reconfigure ports"
unless ($user->admin or $user->port_control);
my $role = $user->portctl_role;
my $acl = $role ? setting('portctl_by_role')->{$role} : undef;
if ($acl and (ref $acl eq q{} or ref $acl eq ref [])) {
# all ports are permitted when the role acl is a device acl
# but check the device anyway
return "forbidden: user [$username] has no right to reconfigure ports"
unless acl_matches($device, $acl);
}
elsif ($acl and ref $acl eq ref {}) {
my $found = false;
foreach my $key (sort keys %$acl) {
# lhs matches device, rhs matches port
next unless $key and $acl->{$key};
if (acl_matches($device, $key)
and acl_matches($port, $acl->{$key})) {
$found = true;
last;
}
}
return "forbidden: user [$username] role [$role] cannot reconfigure port [$name] on [$ip]"
unless $found;
}
elsif ($role) {
return "forbidden: user [$username] is assigned an unknown role"
unless $user->port_control;
}
}
return false;
} }
=head2 get_port( $device, $portname ) =head2 get_port( $device, $portname )

View File

@@ -27,6 +27,7 @@ use App::Netdisco::Util::Web qw/
request_is_api_report request_is_api_report
request_is_api_search request_is_api_search
/; /;
use App::Netdisco::Util::Permission 'acl_matches';
BEGIN { BEGIN {
no warnings 'redefine'; no warnings 'redefine';
@@ -138,6 +139,26 @@ if (setting('extra_web_plugins') and ref [] eq ref setting('extra_web_plugins'))
_load_web_plugins( setting('extra_web_plugins') ); _load_web_plugins( setting('extra_web_plugins') );
} }
foreach my $tag (keys %{ setting('_admin_tasks') }) {
my $code = sub {
# trick the ajax into working as if this were a tabbed page
params->{tab} = $tag;
var(nav => 'admin');
template 'admintask', {
task => setting('_admin_tasks')->{ $tag },
}, { layout => 'main' };
};
if (setting('_admin_tasks')->{ $tag }->{ 'roles' }) {
get "/admin/$tag" => require_any_role setting('_admin_tasks')->{ $tag }->{ 'roles' } => $code;
}
else {
get "/admin/$tag" => require_role admin => $code;
}
}
# after plugins are loaded, add our own template path # after plugins are loaded, add our own template path
push @{ config->{engines}->{netdisco_template_toolkit}->{INCLUDE_PATH} }, push @{ config->{engines}->{netdisco_template_toolkit}->{INCLUDE_PATH} },
setting('views'); setting('views');
@@ -269,8 +290,33 @@ hook 'before_template' => sub {
for grep {$_ ne 'return_url'} keys %{params()}; for grep {$_ ne 'return_url'} keys %{params()};
$tokens->{my_query} = $queryuri->query(); $tokens->{my_query} = $queryuri->query();
# access to logged in user's roles # access to logged in user's roles (modulo RBAC)
$tokens->{user_has_role} = sub { user_has_role(@_) }; $tokens->{user_has_role} = sub {
my ($role, $device) = @_;
return false unless $role;
return user_has_role($role) if $role ne 'port_control';
return false unless user_has_role('port_control');
return true if not $device;
my $user = logged_in_user or return false;
return true unless $user->portctl_role;
my $acl = setting('portctl_by_role')->{$user->portctl_role};
if ($acl and (ref $acl eq q{} or ref $acl eq ref [])) {
return true if acl_matches($device, $acl);
}
elsif ($acl and ref $acl eq ref {}) {
foreach my $key (grep { defined } sort keys %$acl) {
# lhs matches device, rhs matches port
# but we are not interested in the ports
return true if acl_matches($device, $key);
}
}
# assigned an unknown role
return false;
};
# create date ranges from within templates # create date ranges from within templates
$tokens->{to_daterange} = sub { interval_to_daterange(@_) }; $tokens->{to_daterange} = sub { interval_to_daterange(@_) };

View File

@@ -105,23 +105,4 @@ ajax "/ajax/control/admin/snapshot_del" => require_role setting('defanged_admin'
schema(vars->{'tenant'})->resultset('DeviceBrowser')->search({ip => $device->addr})->delete; schema(vars->{'tenant'})->resultset('DeviceBrowser')->search({ip => $device->addr})->delete;
}; };
get '/admin/*' => require_role admin => sub {
my ($tag) = splat;
if (exists setting('_admin_tasks')->{ $tag }) {
# trick the ajax into working as if this were a tabbed page
params->{tab} = $tag;
var(nav => 'admin');
template 'admintask', {
task => setting('_admin_tasks')->{ $tag },
}, { layout => 'main' };
}
else {
var('notfound' => true);
status 'not_found';
template 'index', {}, { layout => 'main' };
}
};
true; true;

View File

@@ -84,7 +84,7 @@ get '/device' => require_login sub {
params->{'tab'} ||= 'details'; params->{'tab'} ||= 'details';
template 'device', { template 'device', {
is_pseudo => $first->is_pseudo, netdisco_device => $first,
display_name => ($others ? $first->ip : ($first->dns || $first->ip)), display_name => ($others ? $first->ip : ($first->dns || $first->ip)),
lgroup_list => [ schema(vars->{'tenant'})->resultset('Device')->get_distinct_col('location') ], lgroup_list => [ schema(vars->{'tenant'})->resultset('Device')->get_distinct_col('location') ],
hgroup_list => setting('host_group_displaynames'), hgroup_list => setting('host_group_displaynames'),

View File

@@ -14,6 +14,7 @@ use NetAddr::IP::Lite ':lower';
register_admin_task({ register_admin_task({
tag => 'topology', tag => 'topology',
label => 'Manual Device Topology', label => 'Manual Device Topology',
roles => [qw/admin port_control/],
}); });
sub _sanity_ok { sub _sanity_ok {

View File

@@ -52,6 +52,10 @@ ajax '/ajax/control/admin/users/add' => require_role setting('defanged_admin') =
)), )),
port_control => (param('port_control') ? \'true' : \'false'), port_control => (param('port_control') ? \'true' : \'false'),
portctl_role =>
((param('port_control') and param('port_control') ne '_global_')
? param('port_control') : ''),
admin => (param('admin') ? \'true' : \'false'), admin => (param('admin') ? \'true' : \'false'),
note => param('note'), note => param('note'),
}); });
@@ -92,6 +96,10 @@ ajax '/ajax/control/admin/users/update' => require_role setting('defanged_admin'
)), )),
port_control => (param('port_control') ? \'true' : \'false'), port_control => (param('port_control') ? \'true' : \'false'),
portctl_role =>
((param('port_control') and param('port_control') ne '_global_')
? param('port_control') : ''),
admin => (param('admin') ? \'true' : \'false'), admin => (param('admin') ? \'true' : \'false'),
note => param('note'), note => param('note'),
}); });
@@ -110,9 +118,11 @@ get '/ajax/content/admin/users' => require_role admin => sub {
return unless scalar @results; return unless scalar @results;
my @port_control_roles = sort keys %{ setting('portctl_by_role') || {} };
if ( request->is_ajax ) { if ( request->is_ajax ) {
template 'ajax/admintask/users.tt', template 'ajax/admintask/users.tt',
{ results => \@results, }, { results => \@results, port_control_roles => \@port_control_roles },
{ layout => undef }; { layout => undef };
} }
else { else {

View File

@@ -251,7 +251,7 @@ get '/ajax/content/device/ports' => require_login sub {
# add acl on port config # add acl on port config
if (param('c_admin') and user_has_role('port_control')) { if (param('c_admin') and user_has_role('port_control')) {
map {$_->{portctl} = (port_reconfig_check($_) ? false : true)} @results; map {$_->{portctl} = (port_reconfig_check($_, $device, logged_in_user) ? false : true)} @results;
} }
# empty set would be a 'no records' msg # empty set would be a 'no records' msg

View File

@@ -17,7 +17,7 @@ register_worker({ phase => 'check' }, sub {
or return Status->error(sprintf "Unknown port name [%s] on device %s", or return Status->error(sprintf "Unknown port name [%s] on device %s",
$job->port, $job->device); $job->port, $job->device);
my $port_reconfig_check = port_reconfig_check(vars->{'port'}); my $port_reconfig_check = port_reconfig_check(vars->{'port'}, $job->device, $job->username);
return Status->error("Cannot alter port: $port_reconfig_check") return Status->error("Cannot alter port: $port_reconfig_check")
if $port_reconfig_check; if $port_reconfig_check;

View File

@@ -19,7 +19,7 @@ register_worker({ phase => 'check' }, sub {
or return Status->error(sprintf "Unknown port name [%s] on device %s", or return Status->error(sprintf "Unknown port name [%s] on device %s",
$job->port, $job->device); $job->port, $job->device);
my $port_reconfig_check = port_reconfig_check(vars->{'port'}); my $port_reconfig_check = port_reconfig_check(vars->{'port'}, $job, $job->username);
return Status->error("Cannot alter port: $port_reconfig_check") return Status->error("Cannot alter port: $port_reconfig_check")
if $port_reconfig_check; if $port_reconfig_check;

View File

@@ -16,7 +16,7 @@ register_worker({ phase => 'check' }, sub {
vars->{'port'} = get_port($device, $pn) vars->{'port'} = get_port($device, $pn)
or return Status->error("Unknown port name [$pn] on device $device"); or return Status->error("Unknown port name [$pn] on device $device");
my $port_reconfig_check = port_reconfig_check(vars->{'port'}); my $port_reconfig_check = port_reconfig_check(vars->{'port'}, $device, $job->username);
return Status->error("Cannot alter port: $port_reconfig_check") return Status->error("Cannot alter port: $port_reconfig_check")
if $port_reconfig_check; if $port_reconfig_check;

View File

@@ -251,6 +251,7 @@ portctl_nowaps: false
portctl_nophones: false portctl_nophones: false
portctl_vlans: false portctl_vlans: false
portctl_uplinks: false portctl_uplinks: false
portctl_by_role: {}
system_port_control_reasons: system_port_control_reasons:
address: 'Address Allocation Abuse' address: 'Address Allocation Abuse'
copyright: 'Copyright Violation' copyright: 'Copyright Violation'

View File

@@ -17,7 +17,7 @@
<tr> <tr>
<td class="nd_center-cell"><input data-form="add" name="fullname" type="text"></td> <td class="nd_center-cell"><input data-form="add" name="fullname" type="text"></td>
<td class="nd_center-cell"><input class="span2" data-form="add" name="username" type="text"></td> <td class="nd_center-cell"><input class="span2" data-form="add" name="username" type="text"></td>
<td class="nd_center-cell"><input class="span2" data-form="add" name="password" type="password"></td> <td class="nd_center-cell"><input class="span1" data-form="add" name="password" type="password"></td>
<td class="nd_center-cell"> <td class="nd_center-cell">
<div class="form-group"> <div class="form-group">
<select class="span2 form-control" data-form="add" name="auth_method"> <select class="span2 form-control" data-form="add" name="auth_method">
@@ -49,7 +49,7 @@
<input class="span2" data-form="update" name="username" type="text" value="[% row.username | html_entity %]"> <input class="span2" data-form="update" name="username" type="text" value="[% row.username | html_entity %]">
</td> </td>
<td class="nd_center-cell"> <td class="nd_center-cell">
<input class="span2" data-form="update" name="password" type="password" value="********"> <input class="span1" data-form="update" name="password" type="password" value="********">
</td> </td>
<td class="nd_center-cell"> <td class="nd_center-cell">
<div class="form-group"> <div class="form-group">
@@ -62,7 +62,15 @@
</div> </div>
</td> </td>
<td class="nd_center-cell"> <td class="nd_center-cell">
<input data-form="update" name="port_control" type="checkbox" [% ' checked="checked"' IF row.port_control %]> <div class="form-group">
<select class="span2 form-control" data-form="update" name="port_control">
<option[% ' selected' IF NOT row.port_control %] value="">Off</option>
<option[% ' selected' IF row.port_control AND row.portctl_role == "" %] value="_global_">Enabled (any port)</option>
[% FOREACH role IN port_control_roles %]
<option[% ' selected' IF row.port_control AND row.portctl_role == role %] value="[% role | html_entity %]">Role: [% role | html_entity %]</option>
[% END %]
</select>
</div>
</td> </td>
<td class="nd_center-cell"> <td class="nd_center-cell">
<input data-form="update" name="admin" type="checkbox" [% ' checked="checked"' IF row.admin %]> <input data-form="update" name="admin" type="checkbox" [% ' checked="checked"' IF row.admin %]>

View File

@@ -1,6 +1,6 @@
[% USE CSV -%] [% USE CSV -%]
[% CSV.dump([ 'Full Name' 'Username' [% CSV.dump([ 'Full Name' 'Username'
'LDAP Auth' 'RADIUS Auth' 'TACACS+ Auth' 'Port Control' 'Administrator' 'Created' 'LDAP Auth' 'RADIUS Auth' 'TACACS+ Auth' 'Port Control' 'Port Control Role' 'Administrator' 'Created'
'Last Login' 'Note']) %] 'Last Login' 'Note']) %]
[% FOREACH row IN results %] [% FOREACH row IN results %]
@@ -11,6 +11,7 @@
[% mylist.push(row.radius) %] [% mylist.push(row.radius) %]
[% mylist.push(row.tacacs) %] [% mylist.push(row.tacacs) %]
[% mylist.push(row.port_control) %] [% mylist.push(row.port_control) %]
[% mylist.push(row.portctl_role) %]
[% mylist.push(row.admin) %] [% mylist.push(row.admin) %]
[% mylist.push(row.created) %] [% mylist.push(row.created) %]
[% mylist.push(row.last_seen) %] [% mylist.push(row.last_seen) %]

View File

@@ -1,4 +1,4 @@
[% SET user_can_port_control = user_has_role('port_control') %] [% SET user_can_port_control = user_has_role('port_control', d) %]
<table class="table table-condensed table-striped"> <table class="table table-condensed table-striped">
<tbody> <tbody>
<tr> <tr>

View File

@@ -1,4 +1,4 @@
[% SET user_can_port_control = user_has_role('port_control') %] [% SET user_can_port_control = user_has_role('port_control', device) %]
<table id="dp-data-table" class="table table-bordered table-striped" width="100%" cellspacing="0"> <table id="dp-data-table" class="table table-bordered table-striped" width="100%" cellspacing="0">
<thead> <thead>
<tr> <tr>

View File

@@ -40,7 +40,7 @@
<li[% ' class="active"' IF params.tab == tab.tag %]><a id="[% tab.tag | html_entity %]_link" href="#[% tab.tag | html_entity %]_pane">[% tab.label | html_entity %]</a></li> <li[% ' class="active"' IF params.tab == tab.tag %]><a id="[% tab.tag | html_entity %]_link" href="#[% tab.tag | html_entity %]_pane">[% tab.label | html_entity %]</a></li>
[% END %] [% END %]
<span id="nd_device-name"> <span id="nd_device-name">
[% IF is_pseudo %]<span class="badge badge-warning">[% END %][% display_name | html_entity %][% IF is_pseudo %]</span>[% END %] [% IF netdisco_device.is_pseudo %]<span class="badge badge-warning">[% END %][% display_name | html_entity %][% IF netdisco_device.is_pseudo %]</span>[% END %]
<a id="nd_csv-download" href="#" download="netdisco.csv">&nbsp; <a id="nd_csv-download" href="#" download="netdisco.csv">&nbsp;
<i id="nd_csv-download-icon" class="text-info icon-file-text-alt icon-large" <i id="nd_csv-download-icon" class="text-info icon-file-text-alt icon-large"
rel="tooltip" data-placement="left" data-offset="5" data-title="Download as CSV"></i></a> rel="tooltip" data-placement="left" data-offset="5" data-title="Download as CSV"></i></a>

View File

@@ -49,7 +49,7 @@
<li><i class="icon-li icon-rss"></i>&nbsp; Wireless Access Point</li> <li><i class="icon-li icon-rss"></i>&nbsp; Wireless Access Point</li>
<li><i class="icon-li icon-book"></i>&nbsp; Archived Data</li> <li><i class="icon-li icon-book"></i>&nbsp; Archived Data</li>
<li><i class="icon-li icon-group"></i>&nbsp; Interface Group</li> <li><i class="icon-li icon-group"></i>&nbsp; Interface Group</li>
[% IF user_has_role('port_control') %] [% IF user_has_role('port_control', netdisco_device) %]
<li><i class="icon-li icon-refresh icon-spin"></i>&nbsp; Click "Update View"</li> <li><i class="icon-li icon-refresh icon-spin"></i>&nbsp; Click "Update View"</li>
[% END %] [% END %]
</ul> </ul>
@@ -63,7 +63,7 @@
<div id="nd_columns" class="collapse in"> <div id="nd_columns" class="collapse in">
<ul class="nd_inputs-list unstyled"> <ul class="nd_inputs-list unstyled">
[% FOREACH item IN settings.port_columns %] [% FOREACH item IN settings.port_columns %]
[% NEXT IF item.name == 'c_admin' AND NOT user_has_role('port_control') %] [% NEXT IF item.name == 'c_admin' AND NOT user_has_role('port_control', netdisco_device) %]
<li> <li>
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" id="[% item.name | html_entity %]" <input type="checkbox" id="[% item.name | html_entity %]"