Diff -- /var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/scim.cgi
Diff
/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/scim.cgi
added on local at 2026-07-01 12:34:39
Added
+242
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to e39b8d5df697
to e39b8d5df697
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.| 1 | #!/usr/bin/perl | |
| 2 | ||
| 3 | ################################################################################################################################## | |
| 4 | # ______ ____ ____ ______ ______ ____ ___ __ ___ ______ _ __ ____ ____ __ __ _ __ _____ ____ | |
| 5 | # / ____// __ \ / __ \ / ____/ / ____// __ \ / | / |/ // ____/| | / // __ \ / __ \ / //_/ | | / /|__ / / __ \ | |
| 6 | # / / / / / // / / // __/ / /_ / /_/ // /| | / /|_/ // __/ | | /| / // / / // /_/ // ,< | | / / /_ < / / / / | |
| 7 | # / /___ / /_/ // /_/ // /___ / __/ / _, _// ___ | / / / // /___ | |/ |/ // /_/ // _, _// /| | | |/ / ___/ /_ / /_/ / | |
| 8 | # \____/ \____//_____//_____/ /_/ /_/ |_|/_/ |_|/_/ /_//_____/ |__/|__/ \____//_/ |_|/_/ |_| |___/ /____/(_)\____/ | |
| 9 | # | |
| 10 | # - Written by: Shawn Pickering | |
| 11 | # - shawn@shawnpickering.com | |
| 12 | ################################################################################################################################## | |
| 13 | use strict; | |
| 14 | use lib '/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com'; | |
| 15 | ||
| 16 | ||
| 17 | ############################################################# | |
| 18 | ## Variables | |
| 19 | ############################################################# | |
| 20 | ||
| 21 | ||
| 22 | ||
| 23 | ############################################################ | |
| 24 | # Modules | |
| 25 | ############################################################ | |
| 26 | use CGI; my $query = new CGI; my $form = $query->Vars; | |
| 27 | use MODS::DBConnect; my $db = new MODS::DBConnect; | |
| 28 | use MODS::PTMatrix::Config; my $config = new MODS::PTMatrix::Config; my $database_name = $config->settings('database_name'); | |
| 29 | use Digest::SHA qw(sha256_hex); | |
| 30 | use JSON::PP; | |
| 31 | ||
| 32 | ||
| 33 | ||
| 34 | ############################################################ | |
| 35 | # Program Controls | |
| 36 | ############################################################ | |
| 37 | $|=1; | |
| 38 | &scim_router; | |
| 39 | ||
| 40 | ||
| 41 | ############################################################ | |
| 42 | # Subroutines | |
| 43 | ############################################################ | |
| 44 | sub respond{ | |
| 45 | my($code, $obj) = @_; | |
| 46 | my $status = $code == 200 ? '200 OK' | |
| 47 | : $code == 201 ? '201 Created' | |
| 48 | : $code == 204 ? '204 No Content' | |
| 49 | : $code == 400 ? '400 Bad Request' | |
| 50 | : $code == 401 ? '401 Unauthorized' | |
| 51 | : $code == 404 ? '404 Not Found' | |
| 52 | : $code == 405 ? '405 Method Not Allowed' | |
| 53 | : "$code Status"; | |
| 54 | print qq~Status: $status\nContent-Type: application/scim+json\n\n~; | |
| 55 | print JSON::PP::encode_json($obj || {}); | |
| 56 | exit; | |
| 57 | } | |
| 58 | ||
| 59 | sub scim_error{ | |
| 60 | my($code, $detail) = @_; | |
| 61 | &respond($code, { | |
| 62 | schemas => ['urn:ietf:params:scim:api:messages:2.0:Error'], | |
| 63 | detail => $detail, | |
| 64 | status => "$code", | |
| 65 | }); | |
| 66 | } | |
| 67 | ||
| 68 | sub sq{ | |
| 69 | my $s = shift // ''; | |
| 70 | $s =~ s/[\\']/\\$&/g; | |
| 71 | return $s; | |
| 72 | } | |
| 73 | ||
| 74 | sub user_to_scim{ | |
| 75 | my($u) = @_; | |
| 76 | return { | |
| 77 | schemas => ['urn:ietf:params:scim:schemas:core:2.0:User'], | |
| 78 | id => "$u->{id}", | |
| 79 | externalId => $u->{sso_external_id} // '', | |
| 80 | userName => $u->{email}, | |
| 81 | active => ($u->{account_status} eq 'active') ? JSON::PP::true() : JSON::PP::false(), | |
| 82 | name => { formatted => $u->{display_name} }, | |
| 83 | emails => [{ value => $u->{email}, primary => JSON::PP::true() }], | |
| 84 | meta => { | |
| 85 | resourceType => 'User', | |
| 86 | location => "/scim.cgi?path=/Users/$u->{id}", | |
| 87 | }, | |
| 88 | }; | |
| 89 | } | |
| 90 | ||
| 91 | sub scim_router{ | |
| 92 | #SCIM 2.0 endpoint - IdPs POST/PUT/PATCH user lifecycle events here. | |
| 93 | #Bearer token maps to a company via company_sso_configs.scim_token_hash. | |
| 94 | ||
| 95 | my $method = $ENV{REQUEST_METHOD} || 'GET'; | |
| 96 | my $path = $form->{path} || '/ServiceProviderConfig'; | |
| 97 | ||
| 98 | #Bearer header lookup | |
| 99 | my $auth_hdr = $ENV{HTTP_AUTHORIZATION} || ''; | |
| 100 | my $token = ($auth_hdr =~ /^Bearer\s+([A-Za-z0-9]+)/) ? $1 : ''; | |
| 101 | ||
| 102 | #Anyone can hit /ServiceProviderConfig - it's published metadata | |
| 103 | if($path eq '/ServiceProviderConfig'){ | |
| 104 | &respond(200, { | |
| 105 | schemas => ['urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'], | |
| 106 | documentationUri => 'https://ptmatrix.3dshawn.com/sso_setup.cgi', | |
| 107 | patch => { supported => JSON::PP::true() }, | |
| 108 | bulk => { supported => JSON::PP::false(), maxOperations => 0 }, | |
| 109 | filter => { supported => JSON::PP::true(), maxResults => 200 }, | |
| 110 | changePassword => { supported => JSON::PP::false() }, | |
| 111 | sort => { supported => JSON::PP::false() }, | |
| 112 | etag => { supported => JSON::PP::false() }, | |
| 113 | authenticationSchemes => [{ | |
| 114 | type => 'oauthbearertoken', | |
| 115 | name => 'OAuth Bearer Token', | |
| 116 | description => 'Authentication via the SCIM token issued in /sso_setup.cgi', | |
| 117 | primary => JSON::PP::true(), | |
| 118 | }], | |
| 119 | }); | |
| 120 | } | |
| 121 | ||
| 122 | &scim_error(401, 'missing or malformed bearer token') unless $token; | |
| 123 | ||
| 124 | my $hash = sha256_hex($token); | |
| 125 | my $dbh = $db->db_connect(); | |
| 126 | &scim_error(500, 'database unavailable') unless $dbh; | |
| 127 | ||
| 128 | my $sso = $db->db_readwrite($dbh, qq~SELECT * FROM `${database_name}`.company_sso_configs WHERE scim_token_hash='$hash' LIMIT 1~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 129 | &scim_error(401, 'token not recognised') unless $sso && $sso->{id}; | |
| 130 | ||
| 131 | my $cid = $sso->{company_id} + 0; | |
| 132 | ||
| 133 | #Read the request body for write methods | |
| 134 | my $body; | |
| 135 | if($method ne 'GET' && $method ne 'DELETE'){ | |
| 136 | local $/; | |
| 137 | $body = <STDIN>; | |
| 138 | if(length($body || '')){ | |
| 139 | $body = eval { JSON::PP::decode_json($body) }; | |
| 140 | &scim_error(400, 'body is not valid JSON') unless ref($body) eq 'HASH'; | |
| 141 | } | |
| 142 | } | |
| 143 | ||
| 144 | #---- POST /Users - create --------------------------------------------- | |
| 145 | if($path eq '/Users' && $method eq 'POST'){ | |
| 146 | my $email = $body->{userName} || $body->{emails}[0]{value} || ''; | |
| 147 | &scim_error(400, 'userName / emails required') unless $email; | |
| 148 | my $name = $body->{name}{formatted} || (($body->{name}{givenName} || '') . ' ' . ($body->{name}{familyName} || '')); | |
| 149 | $name =~ s/^\s+|\s+$//g; | |
| 150 | $name ||= $email; | |
| 151 | my $ext = $body->{externalId} || ''; | |
| 152 | my $active = exists $body->{active} ? ($body->{active} ? 1 : 0) : 1; | |
| 153 | ||
| 154 | my $sq_email = &sq($email); | |
| 155 | my $existing = $db->db_readwrite($dbh, qq~SELECT id FROM `${database_name}`.users WHERE email='$sq_email' LIMIT 1~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 156 | if($existing && $existing->{id}){ | |
| 157 | $db->db_disconnect($dbh); | |
| 158 | &scim_error(409, 'user with this email already exists'); | |
| 159 | } | |
| 160 | ||
| 161 | my $sq_name = &sq($name); | |
| 162 | my $sq_ext = &sq($ext); | |
| 163 | my $st = $active ? 'active' : 'suspended'; | |
| 164 | my $sql = qq~INSERT INTO `${database_name}`.users SET company_id='$cid', email='$sq_email', password_hash='!SSO_ONLY!', display_name='$sq_name', role='member', auth_provider='sso', sso_external_id='$sq_ext', sso_last_sync_at=NOW(), account_status='$st', email_verified_at=NOW(), created_at=NOW()~; | |
| 165 | my $r = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__); | |
| 166 | my $uid = $r->{mysql_insertid}; | |
| 167 | ||
| 168 | $db->db_readwrite($dbh, qq~INSERT INTO `${database_name}`.sso_provisioning_log (company_id, sso_config_id, direction, action, external_id, email, user_id, status) VALUES ('$cid', '$sso->{id}', 'inbound', 'user.create', '$sq_ext', '$sq_email', '$uid', 'ok')~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 169 | ||
| 170 | my $u = $db->db_readwrite($dbh, qq~SELECT * FROM `${database_name}`.users WHERE id='$uid'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 171 | $db->db_disconnect($dbh); | |
| 172 | &respond(201, &user_to_scim($u)); | |
| 173 | } | |
| 174 | ||
| 175 | #---- GET /Users - list (paged) ---------------------------------------- | |
| 176 | if($path eq '/Users' && $method eq 'GET'){ | |
| 177 | my $start = ($form->{startIndex} || 1) + 0; $start = 1 if $start < 1; | |
| 178 | my $count = ($form->{count} || 50) + 0; $count = 200 if $count > 200; | |
| 179 | my $offset = $start - 1; | |
| 180 | ||
| 181 | my @rows = $db->db_readwrite_multiple($dbh, qq~SELECT * FROM `${database_name}`.users WHERE company_id='$cid' AND auth_provider='sso' ORDER BY id LIMIT $count OFFSET $offset~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 182 | my $total_r = $db->db_readwrite($dbh, qq~SELECT COUNT(*) AS n FROM `${database_name}`.users WHERE company_id='$cid' AND auth_provider='sso'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 183 | $db->db_disconnect($dbh); | |
| 184 | ||
| 185 | &respond(200, { | |
| 186 | schemas => ['urn:ietf:params:scim:api:messages:2.0:ListResponse'], | |
| 187 | totalResults => ($total_r->{n} || 0) + 0, | |
| 188 | startIndex => $start, | |
| 189 | itemsPerPage => scalar(@rows) + 0, | |
| 190 | Resources => [ map { &user_to_scim($_) } @rows ], | |
| 191 | }); | |
| 192 | } | |
| 193 | ||
| 194 | #---- /Users/<id> - GET / PUT / PATCH / DELETE ------------------------- | |
| 195 | if($path =~ m{^/Users/(\d+)$}){ | |
| 196 | my $uid = $1; | |
| 197 | my $u = $db->db_readwrite($dbh, qq~SELECT * FROM `${database_name}`.users WHERE id='$uid' AND company_id='$cid' LIMIT 1~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 198 | if(!$u || !$u->{id}){$db->db_disconnect($dbh); &scim_error(404, 'user not found');} | |
| 199 | ||
| 200 | if($method eq 'GET'){ | |
| 201 | $db->db_disconnect($dbh); | |
| 202 | &respond(200, &user_to_scim($u)); | |
| 203 | } | |
| 204 | ||
| 205 | if($method eq 'PUT' || $method eq 'PATCH'){ | |
| 206 | #Minimal PATCH - top-level fields only | |
| 207 | my @sets; | |
| 208 | if(defined $body->{active}){ | |
| 209 | my $st = $body->{active} ? 'active' : 'suspended'; | |
| 210 | push @sets, "account_status='$st'"; | |
| 211 | } | |
| 212 | if($body->{name}{formatted}){push @sets, "display_name='" . &sq($body->{name}{formatted}) . "'";} | |
| 213 | if($body->{userName}) {push @sets, "email='" . &sq($body->{userName}) . "'";} | |
| 214 | if($body->{externalId}) {push @sets, "sso_external_id='" . &sq($body->{externalId}) . "'";} | |
| 215 | push @sets, "sso_last_sync_at=NOW()"; | |
| 216 | if(@sets){ | |
| 217 | my $set = join(', ', @sets); | |
| 218 | $db->db_readwrite($dbh, qq~UPDATE `${database_name}`.users SET $set WHERE id='$uid'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 219 | } | |
| 220 | $db->db_readwrite($dbh, qq~INSERT INTO `${database_name}`.sso_provisioning_log (company_id, sso_config_id, direction, action, user_id, status) VALUES ('$cid', '$sso->{id}', 'inbound', 'user.update', '$uid', 'ok')~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 221 | ||
| 222 | my $u2 = $db->db_readwrite($dbh, qq~SELECT * FROM `${database_name}`.users WHERE id='$uid'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 223 | $db->db_disconnect($dbh); | |
| 224 | &respond(200, &user_to_scim($u2)); | |
| 225 | } | |
| 226 | ||
| 227 | if($method eq 'DELETE'){ | |
| 228 | #Soft-suspend - kill sessions but keep the row | |
| 229 | $db->db_readwrite($dbh, qq~UPDATE `${database_name}`.users SET account_status='suspended', sso_last_sync_at=NOW() WHERE id='$uid'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 230 | $db->db_readwrite($dbh, qq~UPDATE `${database_name}`.user_sessions SET revoked_at=NOW() WHERE user_id='$uid' AND revoked_at IS NULL~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 231 | $db->db_readwrite($dbh, qq~INSERT INTO `${database_name}`.sso_provisioning_log (company_id, sso_config_id, direction, action, user_id, status, message) VALUES ('$cid', '$sso->{id}', 'inbound', 'user.deactivate', '$uid', 'ok', 'soft-suspended')~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 232 | $db->db_disconnect($dbh); | |
| 233 | &respond(204, {}); | |
| 234 | } | |
| 235 | ||
| 236 | $db->db_disconnect($dbh); | |
| 237 | &scim_error(405, "method $method not allowed on /Users/<id>"); | |
| 238 | } | |
| 239 | ||
| 240 | $db->db_disconnect($dbh); | |
| 241 | &scim_error(404, "no SCIM route at $path"); | |
| 242 | } |