Diff -- /var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/scim.cgi

O Operator
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
Restore this content →
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##################################################################################################################################
13use strict;
14use lib '/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com';
15
16
17#############################################################
18## Variables
19#############################################################
20
21
22
23############################################################
24# Modules
25############################################################
26use CGI; my $query = new CGI; my $form = $query->Vars;
27use MODS::DBConnect; my $db = new MODS::DBConnect;
28use MODS::PTMatrix::Config; my $config = new MODS::PTMatrix::Config; my $database_name = $config->settings('database_name');
29use Digest::SHA qw(sha256_hex);
30use JSON::PP;
31
32
33
34############################################################
35# Program Controls
36############################################################
37$|=1;
38&scim_router;
39
40
41############################################################
42# Subroutines
43############################################################
44sub 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
59sub 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
68sub sq{
69 my $s = shift // '';
70 $s =~ s/[\\']/\\$&/g;
71 return $s;
72}
73
74sub 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
91sub 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}