• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IdentityPython / pyFF / 513

24 Jan 2019 - 20:49 coverage: 82.502%. First build
513

Pull #158

travis-ci

web-flow
utf-8 border in store implementation
Pull Request #158: Py3 compat

135 of 157 new or added lines in 11 files covered. (85.99%)

2744 of 3326 relevant lines covered (82.5%)

2.48 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.46
/src/pyff/builtins.py
1
"""Package that contains the basic set of pipes - functions that can be used to put together a processing pipeling
2
for pyFF.
3
"""
4

5
import base64
3×
6
import hashlib
3×
7
import json
3×
8
import sys
3×
9
import traceback
3×
10
from copy import deepcopy
3×
11
from datetime import datetime
3×
12
from distutils.util import strtobool
3×
13
import operator
3×
14
import os
3×
15
import re
3×
16
import xmlsec
3×
17
from iso8601 import iso8601
3×
18
from lxml.etree import DocumentInvalid
3×
19
from .constants import NS
3×
20
from .decorators import deprecated
3×
21
from .logs import get_log
3×
22
from .pipes import Plumbing, PipeException, PipelineCallback, pipe
3×
23
from .utils import total_seconds, dumptree, safe_write, root, with_tree, duration2timedelta, xslt_transform, \
3×
24
    validate_document, hash_id
25
from .samlmd import sort_entities, iter_entities, annotate_entity, set_entity_attributes, \
3×
26
    discojson, set_pubinfo, set_reginfo, find_in_document, entitiesdescriptor
27
from .fetch import Resource
3×
28
from six.moves.urllib_parse import urlparse
3×
29
from .exceptions import MetadataException
3×
30
from .store import make_store_instance
3×
31
import six
3×
32

33
__author__ = 'leifj'
3×
34

35
FILESPEC_REGEX = "([^ \t\n\r\f\v]+)\s+as\s+([^ \t\n\r\f\v]+)"
3×
36
log = get_log(__name__)
3×
37

38

39
@pipe
3×
40
def dump(req, *opts):
41
    """
42
Print a representation of the entities set on stdout. Useful for testing.
43

44
:param req: The request
45
:param opts: Options (unused)
46
:return: None
47
    """
48
    if req.t is not None:
3×
49
        print(dumptree(req.t))
3×
50
    else:
51
        print("<EntitiesDescriptor xmlns=\"{}\"/>".format(NS['md']))
3×
52

53

54
@pipe
3×
55
def end(req, *opts):
56
    """
57
Exit with optional error code and message.
58

59
:param req: The request
60
:param opts: Options (unused)
61
:return: None
62

63
**Examples**
64

65
.. code-block:: yaml
66

67
    - end
68
    - unreachable
69

70
**Warning** This is very bad if used with pyffd - the server will stop running. If you just want to
71
break out of the pipeline, use break instead.
72

73
    """
74
    code = 0
3×
75
    if req.args is not None:
3×
76
        code = req.args.get('code', 0)
3×
77
        msg = req.args.get('message', None)
3×
78
        if msg is not None:
3×
79
            print(msg)
3×
80
    sys.exit(code)
3×
81

82

83
@pipe
3×
84
def fork(req, *opts):
85
    """
86
Make a copy of the working tree and process the arguments as a pipleline. This essentially resets the working
87
tree and allows a new plumbing to run. Useful for producing multiple outputs from a single source.
88

89
:param req: The request
90
:param opts: Options (unused)
91
:return: None
92

93
**Examples**
94

95
.. code-block:: yaml
96

97
    - select  # select all entities
98
    - fork:
99
        - certreport
100
        - publish:
101
             output: "/tmp/annotated.xml"
102
    - fork:
103
        - xslt:
104
             stylesheet: tidy.xml
105
        - publish:
106
             output: "/tmp/clean.xml"
107

108
The second fork in this example is strictly speaking not necessary since the main plumbing is still active
109
but it may help to structure your plumbings this way.
110

111
**Merging**
112

113
Normally the result of the "inner" plumbing is disgarded - unless published or emit:ed to a calling client
114
in the case of the MDX server - but by adding 'merge' to the options with an optional 'merge strategy' the
115
behaviour can be changed to merge the result of the inner pipeline back to the parent working document.
116

117
The default merge strategy is 'replace_existing' which replaces each EntityDescriptor found in the resulting
118
document in the parent document (using the entityID as a pointer). Any python module path ('a.mod.u.le:callable')
119
ending in a callable is accepted. If the path doesn't contain a ':' then it is assumed to reference one of the
120
standard merge strategies in pyff.merge_strategies.
121

122
For instance the following block can be used to set an attribute on a single entity:
123

124
.. code-block:: yaml
125

126
    - fork merge:
127
        - select: http://sp.example.com/shibboleth-sp
128
        - setattr:
129
            attribute: value
130

131

132
Note that unless you have a select statement before your fork merge you'll be merging into an empty
133
active document which with the default merge strategy of replace_existing will result in an empty
134
active document. To avoid this do a select before your fork, thus:
135

136
.. code-block:: yaml
137

138
    - select
139
    - fork merge:
140
        - select: http://sp.example.com/shibboleth-sp
141
        - setattr:
142
            attribute: value
143

144
    """
145
    nt = None
3×
146
    if req.t is not None:
3×
147
        nt = deepcopy(req.t)
3×
148

149
    ip = Plumbing(pipeline=req.args, pid="%s.fork" % req.plumbing.pid)
3×
150
    ireq = Plumbing.Request(ip, req.md, nt)
3×
151
    ip.iprocess(ireq)
3×
152

153
    if req.t is not None and ireq.t is not None and len(root(ireq.t)) > 0:
3×
154
        if 'merge' in opts:
3×
155
            sn = "pyff.merge_strategies:replace_existing"
3×
156
            if opts[-1] != 'merge':
3×
157
                sn = opts[-1]
3×
158
            req.md.store.merge(req.t, ireq.t, strategy_name=sn)
3×
159

160
    return req.t
3×
161

162

163
@pipe(name='any')
3×
164
def _any(lst, d):
165
    for x in lst:
3×
166
        if x in d:
3×
167
            if type(d) == dict:
3×
168
                return d[x]
3×
169
            else:
170
                return True
!
171
    return False
3×
172

173

174
@pipe(name='break')
3×
175
def _break(req, *opts):
176
    """
177
Break out of a pipeline.
178

179
:param req: The request
180
:param opts: Options (unused)
181
:return: None
182

183
This sets the 'done' request property to True which causes the pipeline to terminate at that point. The method name
184
is '_break' but the keyword is 'break' to avoid conflicting with python builtin methods.
185

186
**Examples**
187

188
.. code-block:: yaml
189

190
    - one
191
    - two
192
    - break
193
    - unreachable
194

195
    """
196
    req.done = True
3×
197
    return req.t
3×
198

199

200
@pipe(name='pipe')
3×
201
def _pipe(req, *opts):
202
    """
203
Run the argument list as a pipleine.
204

205
:param req: The request
206
:param opts: Options (unused)
207
:return: None
208

209
Unlike fork, pipe does not copy the working document but instead operates on the current active document. The done
210
request property is reset to False after the pipeline has been processed. This allows for a classical switch/case
211
flow using the following construction:
212

213
.. code-block:: yaml
214

215
    - pipe:
216
        - when a:
217
            - one
218
            - break
219
        - when b:
220
            - two
221
            - break
222

223
In this case if 'a' is present in the request state, then 'one' will be executed and the 'when b' condition will not
224
be tested at all. Note that at the topmost level the pipe is implicit and may be left out.
225

226
.. code-block:: yaml
227

228
    - pipe:
229
        - one
230
        - two
231

232
is equivalent to
233

234
.. code-block:: yaml
235

236
    - one
237
    - two
238

239
    """
240
    # req.process(Plumbing(pipeline=req.args, pid="%s.pipe" % req.plumbing.pid))
241
    ot = Plumbing(pipeline=req.args, pid="%s.pipe" % req.plumbing.id).iprocess(req)
3×
242
    req.done = False
3×
243
    return ot
3×
244

245

246
@pipe
3×
247
def when(req, condition, *values):
248
    """
249
Conditionally execute part of the pipeline.
250

251
:param req: The request
252
:param condition: The condition key
253
:param values: The condition values
254
:return: None
255

256
The inner pipeline is executed if the at least one of the condition values is present for the specified key in
257
the request state.
258

259
**Examples**
260

261
.. code-block:: yaml
262

263
    - when foo
264
        - something
265
    - when bar bill
266
        - other
267

268
The condition operates on the state: if 'foo' is present in the state (with any value), then the something branch is
269
followed. If 'bar' is present in the state with the value 'bill' then the other branch is followed.
270
    """
271
    c = req.state.get(condition, None)
3×
272
    if c is not None and (not values or _any(values, c)):
3×
273
        return Plumbing(pipeline=req.args, pid="%s.when" % req.plumbing.id).iprocess(req)
3×
274
    return req.t
3×
275

276

277
@pipe
3×
278
def info(req, *opts):
279
    """
280
Dumps the working document on stdout. Useful for testing.
281

282
:param req: The request
283
:param opts: Options (unused)
284
:return: None
285

286
    """
287
    if req.t is None:
3×
288
        raise PipeException("Your pipeline is missing a select statement.")
3×
289

290
    for e in req.t.xpath("//md:EntityDescriptor", namespaces=NS, smart_strings=False):
3×
291
        print(e.get('entityID'))
3×
292
    return req.t
3×
293

294

295
@pipe
3×
296
def sort(req, *opts):
297
    """
298
Sorts the working entities by the value returned by the given xpath.
299
By default, entities are sorted by 'entityID' when the 'order_by [xpath]' option is omitted and
300
otherwise as second criteria.
301
Entities where no value exists for a given xpath are sorted last.
302

303
:param req: The request
304
:param opts: Options: <order_by [xpath]> (see bellow)
305
:return: None
306

307
Options are put directly after "sort". E.g:
308

309
.. code-block:: yaml
310

311
    - sort order_by [xpath]
312

313
**Options**
314
- order_by [xpath] : xpath expression selecting to the value used for sorting the entities.
315
    """
316
    if req.t is None:
3×
317
        raise PipeException("Unable to sort empty document.")
!
318

319
    opts = dict(list(zip(opts[0:1], [" ".join(opts[1:])])))
3×
320
    opts.setdefault('order_by', None)
3×
321
    sort_entities(req.t, opts['order_by'])
3×
322

323
    return req.t
3×
324

325

326
@pipe
3×
327
def publish(req, *opts):
328
    """
329
Publish the working document in XML form.
330

331
:param req: The request
332
:param opts: Options (unused)
333
:return: None
334

335
 Publish takes one argument: path to a file where the document tree will be written.
336

337
**Examples**
338

339
.. code-block:: yaml
340

341
    - publish: /tmp/idp.xml
342
    """
343

344
    if req.t is None:
3×
345
        raise PipeException("Empty document submitted for publication")
3×
346

347
    if req.args is None:
3×
348
        raise PipeException("publish must specify output")
!
349

350
    try:
3×
351
        validate_document(req.t)
3×
352
    except DocumentInvalid as ex:
!
353
        log.error(ex.error_log)
!
354
        raise PipeException("XML schema validation failed")
!
355

356
    output_file = None
3×
357
    if type(req.args) is dict:
3×
358
        output_file = req.args.get("output", None)
3×
359
    else:
360
        output_file = req.args[0]
3×
361
    if output_file is not None:
3×
362
        output_file = output_file.strip()
3×
363
        resource_name = output_file
3×
364
        m = re.match(FILESPEC_REGEX, output_file)
3×
365
        if m:
3×
366
            output_file = m.group(1)
!
367
            resource_name = m.group(2)
!
368
        out = output_file
3×
369
        if os.path.isdir(output_file):
3×
370
            out = "{}.xml".format(os.path.join(output_file, req.id))
!
371

372
        data = dumptree(req.t)
3×
373

374
        safe_write(out, data)
3×
375
        req.store.update(req.t, tid=resource_name)  # TODO maybe this is not the right thing to do anymore
3×
376
    return req.t
3×
377

378

379
@pipe
3×
380
@deprecated(reason="stats subsystem was removed")
3×
381
def loadstats(req, *opts):
382
    """
383
    Log (INFO) information about the result of the last call to load
384
    :param req: The request
385
    :param opts: Options: (none)
386
    :return: None
387
    """
388
    log.info("pyff loadstats has been deprecated")
!
389

390

391
@pipe
3×
392
@deprecated(reason="replaced with load")
3×
393
def remote(req, *opts):
394
    """Deprecated. Calls :py:mod:`pyff.pipes.builtins.load`.
395
    """
396
    return load(req, opts)
!
397

398

399
@pipe
3×
400
@deprecated(reason="replaced with load")
3×
401
def local(req, *opts):
402
    """Deprecated. Calls :py:mod:`pyff.pipes.builtins.load`.
403
    """
404
    return load(req, opts)
!
405

406

407
@pipe
3×
408
@deprecated(reason="replaced with load")
3×
409
def _fetch(req, *opts):
410
    return load(req, *opts)
!
411

412

413
@pipe
3×
414
def load(req, *opts):
415
    """
416
General-purpose resource fetcher.
417

418
    :param req: The request
419
    :param opts: Options: See "Options" below
420
    :return: None
421

422
Supports both remote and local resources. Fetching remote resources is done in parallel using threads.
423

424
Note: When downloading remote files over HTTPS the TLS server certificate is not validated.
425
Note: Default behaviour is to ignore metadata files or entities in MD files that cannot be loaded
426

427
Options are put directly after "load". E.g:
428

429
.. code-block:: yaml
430

431
    - load fail_on_error True filter_invalid False:
432
      - http://example.com/some_remote_metadata.xml
433
      - local_file.xml
434
      - /opt/directory_containing_md_files/
435

436
**Options**
437
Defaults are marked with (*)
438
- max_workers <5> : Number of parallel threads to use for loading MD files
439
- timeout <120> : Socket timeout when downloading files
440
- validate <True*|False> : When true downloaded metadata files are validated (schema validation)
441
- fail_on_error <True|False*> : Control whether an error during download, parsing or (optional)validatation of a MD file
442
                                does not abort processing of the pipeline. When true a failure aborts and causes pyff
443
                                to exit with a non zero exit code. Otherwise errors are logged but ignored.
444
- filter_invalid <True*|False> : Controls validation behaviour. When true Entities that fail validation are filtered
445
                                 I.e. are not loaded. When false the entire metadata file is either loaded, or not.
446
                                 fail_on_error controls whether failure to validating the entire MD file will abort
447
                                 processing of the pipeline.
448
    """
449
    opts = dict(list(zip(opts[::2], opts[1::2])))
3×
450
    opts.setdefault('timeout', 120)
3×
451
    opts.setdefault('max_workers', 5)
3×
452
    opts.setdefault('validate', "True")
3×
453
    opts.setdefault('fail_on_error', "False")
3×
454
    opts.setdefault('filter_invalid', "True")
3×
455
    opts['validate'] = bool(strtobool(opts['validate']))
3×
456
    opts['fail_on_error'] = bool(strtobool(opts['fail_on_error']))
3×
457
    opts['filter_invalid'] = bool(strtobool(opts['filter_invalid']))
3×
458

459
    remotes = []
3×
460
    store = make_store_instance()  # start the load process by creating a provisional store object
3×
461
    req._store = store
3×
462
    for x in req.args:
3×
463
        x = x.strip()
3×
464
        log.debug("load parsing '%s'" % x)
3×
465
        r = x.split()
3×
466

467
        assert len(r) in range(1, 8), PipeException(
3×
468
            "Usage: load resource [as url] [[verify] verification] [via pipeline] [cleanup pipeline]")
469

470
        url = r.pop(0)
3×
471
        params = dict()
3×
472

473
        while len(r) > 0:
3×
474
            elt = r.pop(0)
3×
475
            if elt in ("as", "verify", "via", "cleanup"):
3×
476
                if len(r) > 0:
3×
477
                    params[elt] = r.pop(0)
3×
478
                else:
479
                    raise PipeException(
!
480
                        "Usage: load resource [as url] [[verify] verification] [via pipeline] [cleanup pipeline]")
481
            else:
482
                params['verify'] = elt
3×
483

484
        for elt in ("verify", "via", "cleanup"):
3×
485
            params.setdefault(elt, None)
3×
486

487
        params.setdefault('as', url)
3×
488

489
        if params['via'] is not None:
3×
490
            params['via'] = PipelineCallback(params['via'], req, store=store)
3×
491

492
        if params['cleanup'] is not None:
3×
493
            params['cleanup'] = PipelineCallback(params['cleanup'], req, store=store)
3×
494

495
        params.update(opts)
3×
496

497
        req.md.rm.add(Resource(url, **params))
3×
498

499
    log.debug("Refreshing all resources")
3×
500
    req.md.rm.reload(fail_on_error=bool(opts['fail_on_error']), store=store)
3×
501
    req._store = None
3×
502
    req.md.store = store  # commit the store
3×
503

504

505
def _select_args(req):
3×
506
    args = req.args
3×
507
    if args is None and 'select' in req.state:
3×
508
        args = [req.state.get('select')]
3×
509
    if args is None:
3×
510
        args = req.store.collections()
3×
511
    if args is None or not args:
3×
512
        args = req.store.lookup('entities')
!
513
    if args is None or not args:
3×
514
        args = []
!
515

516
    log.debug("selecting using args: %s" % args)
3×
517

518
    return args
3×
519

520

521
@pipe
3×
522
def select(req, *opts):
523
    """
524
Select a set of EntityDescriptor elements as the working document.
525

526
:param req: The request
527
:param opts: Options - used for select alias
528
:return: returns the result of the operation as a working document
529

530
Select picks and expands elements (with optional filtering) from the active repository you setup using calls
531
to :py:mod:`pyff.pipes.builtins.load`. See :py:mod:`pyff.mdrepo.MDRepository.lookup` for a description of the syntax for
532
selectors.
533

534
**Examples**
535

536
.. code-block:: yaml
537

538
    - select
539

540
This would select all entities in the active repository.
541

542
.. code-block:: yaml
543

544
    - select: "/var/local-metadata"
545

546
This would select all entities found in the directory /var/local-metadata. You must have a call to local to load
547
entities from this directory before select statement.
548

549
.. code-block:: yaml
550

551
    - select: "/var/local-metadata!//md:EntityDescriptor[md:IDPSSODescriptor]"
552

553
This would selects all IdPs from /var/local-metadata
554

555
.. code-block:: yaml
556

557
    - select: "!//md:EntityDescriptor[md:SPSSODescriptor]"
558

559
This would select all SPs
560

561
Select statements are not cumulative - a select followed by another select in the plumbing resets the
562
working douments to the result of the second select.
563

564
Most statements except local and remote depend on having a select somewhere in your plumbing and will
565
stop the plumbing if the current working document is empty. For instance, running
566

567
.. code-block:: yaml
568

569
    - select: "!//md:EntityDescriptor[md:SPSSODescriptor]"
570

571
would terminate the plumbing at select if there are no SPs in the local repository. This is useful in
572
combination with fork for handling multiple cases in your plumbings.
573

574
The 'as' keyword allows a select to be stored as an alias in the local repository. For instance
575

576
.. code-block:: yaml
577

578
    - select as /foo-2.0: "!//md:EntityDescriptor[md:IDPSSODescriptor]"
579

580
would allow you to use /foo-2.0.json to refer to the JSON-version of all IdPs in the current repository.
581
Note that you should not include an extension in your "as foo-bla-something" since that would make your
582
alias invisible for anything except the corresponding mime type.
583
    """
584
    args = _select_args(req)
3×
585
    name = req.plumbing.id
3×
586
    if len(opts) > 0:
3×
587
        if opts[0] != 'as' and len(opts) == 1:
3×
588
            name = opts[0]
!
589
        if opts[0] == 'as' and len(opts) == 2:
3×
590
            name = opts[1]
3×
591

592
    ot = entitiesdescriptor(args, name, lookup_fn=req.md.store.select)
3×
593
    if ot is None:
3×
594
        raise PipeException("empty select - stop")
3×
595

596
    if req.plumbing.id != name:
3×
597
        log.debug("storing synthentic collection {}".format(name))
3×
598
        n = req.store.update(ot, name)
3×
599

600
    return ot
3×
601

602

603
@pipe(name="filter")
3×
604
def _filter(req, *opts):
605
    """
606
    Refines the working document by applying a filter. The filter expression is a subset of the
607
    select semantics and syntax:
608

609
.. code-block:: yaml
610

611
    - filter:
612
        - "!//md:EntityDescriptor[md:SPSSODescriptor]"
613
        - "https://idp.example.com/shibboleth"
614

615
    This would select all SPs and any entity with entityID "https://idp.example.com/shibboleth"
616
    from the current working document and return as the new working document. Filter also supports
617
    the "as <alias>" construction from select allowing new synthetic collections to be created
618
    from filtered documents.
619
    """
620

621
    if req.t is None:
3×
622
        raise PipeException("Unable to filter on an empty document - use select first")
!
623

624
    alias = False
3×
625
    if len(opts) > 0:
3×
626
        if opts[0] != 'as' and len(opts) == 1:
!
627
            name = opts[0]
!
628
            alias = True
!
629
        if opts[0] == 'as' and len(opts) == 2:
!
630
            name = opts[1]
!
631
            alias = True
!
632

633
    name = req.plumbing.id
3×
634
    args = req.args
3×
635
    if args is None or not args:
3×
636
        args = []
!
637

638
    ot = entitiesdescriptor(args, name, lookup_fn=lambda member: find_in_document(req.t, member), copy=False)
3×
639
    if alias:
3×
640
        n = req.store.update(ot, name)
!
641

642
    req.t = None
3×
643

644
    if ot is None:
3×
645
        raise PipeException("empty filter - stop")
3×
646

647
    # print "filter returns %s" % [e for e in iter_entities(ot)]
648
    return ot
3×
649

650

651
@pipe
3×
652
def pick(req, *opts):
653
    """
654
Select a set of EntityDescriptor elements as a working document but don't validate it.
655

656
:param req: The request
657
:param opts: Options (unused)
658
:return: returns the result of the operation as a working document
659

660
Useful for testing. See py:mod:`pyff.pipes.builtins.pick` for more information about selecting the document.
661
    """
662
    args = _select_args(req)
3×
663
    ot = entitiesdescriptor(args, req.plumbing.id, lookup_fn=req.md.store.lookup, validate=False)
3×
664
    if ot is None:
3×
665
        raise PipeException("empty select '%s' - stop" % ",".join(args))
3×
666
    return ot
!
667

668

669
@pipe
3×
670
def first(req, *opts):
671
    """
672
If the working document is a single EntityDescriptor, strip the outer EntitiesDescriptor element and return it.
673

674
:param req: The request
675
:param opts: Options (unused)
676
:return: returns the first entity descriptor if the working document only contains one
677

678
Sometimes (eg when running an MDX pipeline) it is usually expected that if a single EntityDescriptor is being returned
679
then the outer EntitiesDescriptor is stripped. This method does exactly that:
680
    """
681
    if req.t is None:
3×
682
        raise PipeException("Your pipeline is missing a select statement.")
3×
683

684
    gone = object()  # sentinel
3×
685
    entities = iter_entities(req.t)
3×
686
    one = next(entities, gone)
3×
687
    if one is gone:
3×
688
        return req.t  # empty tree - return it as is
!
689

690
    two = next(entities, gone)  # one EntityDescriptor in tree - return just that one
3×
691
    if two is gone:
3×
692
        return one
3×
693

694
    return req.t
3×
695

696

697
@pipe(name='discojson')
3×
698
def _discojson(req, *opts):
699
    """
700
Return a discojuice-compatible json representation of the tree
701

702
:param req: The request
703
:param opts: Options (unusued)
704
:return: returns a JSON array
705
    """
706

707
    if req.t is None:
3×
708
        raise PipeException("Your pipeline is missing a select statement.")
!
709

710
    res = [discojson(e) for e in iter_entities(req.t)]
3×
711
    res.sort(key=operator.itemgetter('title'))
3×
712

713
    return json.dumps(res)
3×
714

715

716
@pipe
3×
717
def sign(req, *opts):
718
    """
719
Sign the working document.
720

721
:param req: The request
722
:param opts: Options (unused)
723
:return: returns the signed working document
724

725
Sign expects a single dict with at least a 'key' key and optionally a 'cert' key. The 'key' argument references
726
either a PKCS#11 uri or the filename containing a PEM-encoded non-password protected private RSA key.
727
The 'cert' argument may be empty in which case the cert is looked up using the PKCS#11 token, or may point
728
to a file containing a PEM-encoded X.509 certificate.
729

730
**PKCS11 URIs**
731

732
A pkcs11 URI has the form
733

734
.. code-block:: xml
735

736
    pkcs11://<absolute path to SO/DLL>[:slot]/<object label>[?pin=<pin>]
737

738
The pin parameter can be used to point to an environment variable containing the pin: "env:<ENV variable>".
739
By default pin is "env:PYKCS11PIN" which tells sign to use the pin found in the PYKCS11PIN environment
740
variable. This is also the default for PyKCS11 which is used to communicate with the PKCS#11 module.
741

742
**Examples**
743

744
.. code-block:: yaml
745

746
    - sign:
747
        key: pkcs11:///usr/lib/libsofthsm.so/signer
748

749
This would sign the document using the key with label 'signer' in slot 0 of the /usr/lib/libsofthsm.so module.
750
Note that you may need to run pyff with env PYKCS11PIN=<pin> .... for this to work. Consult the documentation
751
of your PKCS#11 module to find out about any other configuration you may need.
752

753
.. code-block:: yaml
754

755
    - sign:
756
        key: signer.key
757
        cert: signer.crt
758

759
This example signs the document using the plain key and cert found in the signer.key and signer.crt files.
760
    """
761
    if req.t is None:
3×
762
        raise PipeException("Your pipeline is missing a select statement.")
3×
763

764
    if not type(req.args) is dict:
3×
765
        raise PipeException("Missing key and cert arguments to sign pipe")
!
766

767
    key_file = req.args.get('key', None)
3×
768
    cert_file = req.args.get('cert', None)
3×
769

770
    if key_file is None:
3×
771
        raise PipeException("Missing key argument for sign pipe")
!
772

773
    if cert_file is None:
3×
774
        log.info("Attempting to extract certificate from token...")
!
775

776
    opts = dict()
3×
777
    relt = root(req.t)
3×
778
    idattr = relt.get('ID')
3×
779
    if idattr:
3×
780
        opts['reference_uri'] = "#%s" % idattr
3×
781
    xmlsec.sign(req.t, key_file, cert_file, **opts)
3×
782

783
    return req.t
3×
784

785

786
@pipe
3×
787
def stats(req, *opts):
788
    """
789
Display statistics about the current working document.
790

791
:param req: The request
792
:param opts: Options (unused)
793
:return: always returns the unmodified working document
794

795
**Examples**
796

797
.. code-block:: yaml
798

799
    - stats
800

801
    """
802
    if req.t is None:
3×
803
        raise PipeException("Your pipeline is missing a select statement.")
3×
804

805
    print("---")
3×
806
    print("total size:     {:d}".format(req.store.size()))
3×
807
    if not hasattr(req.t, 'xpath'):
3×
808
        raise PipeException("Unable to call stats on non-XML")
!
809

810
    if req.t is not None:
3×
811
        print("selected:       {:d}".format(len(req.t.xpath("//md:EntityDescriptor", namespaces=NS))))
3×
812
        print("          idps: {:d}".format(
3×
813
            len(req.t.xpath("//md:EntityDescriptor[md:IDPSSODescriptor]", namespaces=NS))))
814
        print(
3×
815
            "           sps: {:d}".format(len(req.t.xpath("//md:EntityDescriptor[md:SPSSODescriptor]", namespaces=NS))))
816
    print("---")
3×
817
    return req.t
3×
818

819

820
@pipe
3×
821
def summary(req, *opts):
822
    """
823
    Display a summary of the repository
824
    :param req:
825
    :param opts:
826
    :return:
827
    """
828
    if req.t is None:
!
829
        raise PipeException("Your pipeline is missing a select statement.")
!
830

831
    return dict(size=req.store.size())
!
832

833

834
@pipe(name='store')
3×
835
def _store(req, *opts):
836
    """
837
Save the working document as separate files
838

839
:param req: The request
840
:param opts: Options (unused)
841
:return: always returns the unmodified working document
842
    
843
Split the working document into EntityDescriptor-parts and save in directory/sha1(@entityID).xml. Note that
844
this does not erase files that may already be in the directory. If you want a "clean" directory, remove it
845
before you call store.
846
    """
847
    if req.t is None:
3×
848
        raise PipeException("Your pipeline is missing a select statement.")
3×
849

850
    if not req.args:
3×
851
        raise PipeException("store requires an argument")
!
852

853
    target_dir = None
3×
854
    if type(req.args) is dict:
3×
855
        target_dir = req.args.get('directory', None)
3×
856
    else:
857
        target_dir = req.args[0]
!
858

859
    if target_dir is not None:
3×
860
        if not os.path.isdir(target_dir):
3×
861
            os.makedirs(target_dir)
3×
862
        for e in iter_entities(req.t):
3×
863
            fn = hash_id(e, prefix=False)
3×
864
            safe_write("%s.xml" % os.path.join(target_dir, fn), dumptree(e, pretty_print=True))
3×
865
    return req.t
3×
866

867

868
@pipe
3×
869
def xslt(req, *opts):
870
    """
871
Transform the working document using an XSLT file.
872

873
:param req: The request
874
:param opts: Options (unused)
875
:return: the transformation result
876

877
Apply an XSLT stylesheet to the working document. The xslt pipe takes a set of keyword arguments. The only required
878
argument is 'stylesheet' which identifies the xslt resource. This is looked up either in the package or as a
879
user-supplied file. The rest of the keyword arguments are made available as string parameters to the XSLT transform.
880

881
**Examples**
882

883
.. code-block:: yaml
884

885
    - xslt:
886
        sylesheet: foo.xsl
887
        x: foo
888
        y: bar
889
    """
890
    if req.t is None:
3×
891
        raise PipeException("Your plumbing is missing a select statement.")
3×
892

893
    stylesheet = req.args.get('stylesheet', None)
3×
894
    if stylesheet is None:
3×
895
        raise PipeException("xslt requires stylesheet")
!
896

897
    params = dict((k, "\'%s\'" % v) for (k, v) in list(req.args.items()))
3×
898
    del params['stylesheet']
3×
899
    try:
3×
900
        return xslt_transform(req.t, stylesheet, params)
3×
901
    except Exception as ex:
!
902
        log.debug(traceback.format_exc())
!
903
        raise ex
!
904

905

906
@pipe
3×
907
def validate(req, *opts):
908
    """
909
Validate the working document
910

911
:param req: The request
912
:param opts: Not used
913
:return: The unmodified tree
914

915

916
Generate an exception unless the working tree validates. Validation is done automatically during publication and
917
loading of metadata so this call is seldom needed.
918
    """
919
    if req.t is not None:
!
920
        validate_document(req.t)
!
921

922
    return req.t
!
923

924

925
@pipe
3×
926
def prune(req, *opts):
927
    """
928
Prune the active tree, removing all elements matching
929

930
:param req: The request
931
:param opts: Not used
932
:return: The tree with all specified elements removed
933

934

935
** Examples**
936
.. code-block:: yaml
937

938
    - prune:
939
        - .//{http://www.w3.org/2000/09/xmldsig#}Signature
940

941
This example would drop all Signature elements. Note the use of namespaces.
942

943
.. code-block:: yaml
944

945
    - prune:
946
        - .//{http://www.w3.org/2000/09/xmldsig#}Signature[1]
947

948
This example would drop the first Signature element only.
949

950
    """
951

952
    if req.t is None:
3×
953
        raise PipeException("Your pipeline is missing a select statement.")
!
954

955
    for path in req.args:
3×
956
        for part in req.t.iterfind(path):
3×
957
            parent = part.getparent()
3×
958
            if parent is not None:
3×
959
                parent.remove(part)
3×
960
            else:  # we just removed the top-level element - return empty tree
961
                return None
!
962

963
    return req.t
3×
964

965

966
@pipe
3×
967
def check_xml_namespaces(req, *opts):
968
    """
969

970
    :param req: The request
971
    :param opts: Options (not used)
972
    :return: always returns the unmodified working document or throws an exception if checks fail
973
    """
974
    if req.t is None:
3×
975
        raise PipeException("Your pipeline is missing a select statement.")
!
976

977
    def _verify(elt):
3×
978
        if isinstance(elt.tag, six.string_types):
3×
979
            for prefix, uri in list(elt.nsmap.items()):
3×
980
                if not uri.startswith('urn:'):
3×
981
                    u = urlparse(uri)
3×
982
                    if u.scheme not in ('http', 'https'):
3×
983
                        raise MetadataException(
3×
984
                            "Namespace URIs must be be http(s) URIs ('{}' declared on {})".format(uri, elt.tag))
985

986
    with_tree(root(req.t), _verify)
3×
987
    return req.t
!
988

989

990
@pipe
3×
991
def certreport(req, *opts):
992
    """
993
Generate a report of the certificates (optionally limited by expiration time or key size) found in the selection.
994

995
:param req: The request
996
:param opts: Options (not used)
997
:return: always returns the unmodified working document
998

999
**Examples**
1000

1001
.. code-block:: yaml
1002

1003
    - certreport:
1004
         error_seconds: 0
1005
         warning_seconds: 864000
1006
         error_bits: 1024
1007
         warning_bits: 2048
1008

1009
For key size checking this will report keys with a size *less* than the size specified, defaulting to errors
1010
for keys smaller than 1024 bits and warnings for keys smaller than 2048 bits. It should be understood as the
1011
minimum key size for each report level, as such everything below will create report entries.
1012

1013
Remember that you need a 'publish' or 'emit' call after certreport in your plumbing to get useful output. PyFF
1014
ships with a couple of xslt transforms that are useful for turning metadata with certreport annotation into
1015
HTML.
1016
    """
1017

1018
    if req.t is None:
3×
1019
        raise PipeException("Your pipeline is missing a select statement.")
3×
1020

1021
    if not req.args:
3×
1022
        req.args = {}
3×
1023

1024
    if type(req.args) is not dict:
3×
1025
        raise PipeException("usage: certreport {warning: 864000, error: 0}")
!
1026

1027
    error_seconds = int(req.args.get('error_seconds', "0"))
3×
1028
    warning_seconds = int(req.args.get('warning_seconds', "864000"))
3×
1029
    error_bits = int(req.args.get('error_bits', "1024"))
3×
1030
    warning_bits = int(req.args.get('warning_bits', "2048"))
3×
1031

1032
    seen = {}
3×
1033
    for eid in req.t.xpath("//md:EntityDescriptor/@entityID",
3×
1034
                           namespaces=NS,
1035
                           smart_strings=False):
1036
        for cd in req.t.xpath("md:EntityDescriptor[@entityID='%s']//ds:X509Certificate" % eid,
3×
1037
                              namespaces=NS,
1038
                              smart_strings=False):
1039
            try:
3×
1040
                cert_pem = cd.text
3×
1041
                cert_der = base64.b64decode(cert_pem)
3×
1042
                m = hashlib.sha1()
3×
1043
                m.update(cert_der)
3×
1044
                fp = m.hexdigest()
3×
1045
                if not seen.get(fp, False):
3×
1046
                    entity_elt = cd.getparent().getparent().getparent().getparent().getparent()
3×
1047
                    seen[fp] = True
3×
1048
                    cdict = xmlsec.utils.b642cert(cert_pem)
3×
1049
                    keysize = cdict['modulus'].bit_length()
3×
1050
                    cert = cdict['cert']
3×
1051
                    if keysize < error_bits:
3×
1052
                        annotate_entity(entity_elt,
!
1053
                                        "certificate-error",
1054
                                        "keysize too small",
1055
                                        "%s has keysize of %s bits (less than %s)" % (cert.getSubject(),
1056
                                                                                      keysize,
1057
                                                                                      error_bits))
1058
                        log.error("%s has keysize of %s" % (eid, keysize))
!
1059
                    elif keysize < warning_bits:
3×
1060
                        annotate_entity(entity_elt,
3×
1061
                                        "certificate-warning",
1062
                                        "keysize small",
1063
                                        "%s has keysize of %s bits (less than %s)" % (cert.getSubject(),
1064
                                                                                      keysize,
1065
                                                                                      warning_bits))
1066
                        log.warn("%s has keysize of %s" % (eid, keysize))
3×
1067

1068
                    notafter = cert.getNotAfter()
3×
1069
                    if notafter is None:
3×
1070
                        annotate_entity(entity_elt,
!
1071
                                        "certificate-error",
1072
                                        "certificate has no expiration time",
1073
                                        "%s has no expiration time" % cert.getSubject())
1074
                    else:
1075
                        try:
3×
1076
                            et = datetime.strptime("%s" % notafter, "%y%m%d%H%M%SZ")
3×
1077
                            now = datetime.now()
3×
1078
                            dt = et - now
3×
1079
                            if total_seconds(dt) < error_seconds:
3×
1080
                                annotate_entity(entity_elt,
3×
1081
                                                "certificate-error",
1082
                                                "certificate has expired",
1083
                                                "%s expired %s ago" % (cert.getSubject(), -dt))
1084
                                log.error("%s expired %s ago" % (eid, -dt))
3×
1085
                            elif total_seconds(dt) < warning_seconds:
3×
1086
                                annotate_entity(entity_elt,
!
1087
                                                "certificate-warning",
1088
                                                "certificate about to expire",
1089
                                                "%s expires in %s" % (cert.getSubject(), dt))
1090
                                log.warn("%s expires in %s" % (eid, dt))
!
1091
                        except ValueError as ex:
!
1092
                            annotate_entity(entity_elt,
!
1093
                                            "certificate-error",
1094
                                            "certificate has unknown expiration time",
1095
                                            "%s unknown expiration time %s" % (cert.getSubject(), notafter))
1096

1097
                    req.store.update(entity_elt)
3×
1098
            except Exception as ex:
!
1099
                log.debug(traceback.format_exc())
!
1100
                log.error(ex)
!
1101

1102

1103
@pipe
3×
1104
def emit(req, ctype="application/xml", *opts):
3×
1105
    """
1106
Returns a UTF-8 encoded representation of the working tree.
1107

1108
:param req: The request
1109
:param ctype: The mimetype of the response.
1110
:param opts: Options (not used)
1111
:return: unicode data
1112

1113
Renders the working tree as text and sets the digest of the tree as the ETag. If the tree has already been rendered as
1114
text by an earlier step the text is returned as utf-8 encoded unicode. The mimetype (ctype) will be set in the
1115
Content-Type HTTP response header.
1116

1117
**Examples**
1118

1119
.. code-block:: yaml
1120

1121
    - emit application/xml:
1122
    - break
1123
    """
1124
    if req.t is None:
3×
1125
        raise PipeException("Your pipeline is missing a select statement.")
3×
1126

1127
    d = req.t
3×
1128
    if hasattr(d, 'getroot') and hasattr(d.getroot, '__call__'):
3×
1129
        nd = d.getroot()
3×
1130
        if nd is None:
3×
1131
            d = str(d)
!
1132
        else:
1133
            d = nd
3×
1134

1135
    if hasattr(d, 'tag'):
3×
1136
        d = dumptree(d)
3×
1137

1138
    if d is not None:
3×
1139
        m = hashlib.sha1()
3×
1140
        if not isinstance(d, six.binary_type):
3×
1141
            d = d.encode("utf-8")
3×
1142
        m.update(d)
3×
1143
        req.state['headers']['ETag'] = m.hexdigest()
3×
1144
    else:
1145
        raise PipeException("Empty")
!
1146

1147
    req.state['headers']['Content-Type'] = ctype
3×
1148
    if six.PY2:
3×
NEW
1149
        d = six.u(d)
!
1150
    return d
3×
1151

1152

1153
@pipe
3×
1154
def signcerts(req, *opts):
1155
    """
1156
Logs the fingerprints of the signing certs found in the current working tree.
1157

1158
:param req: The request
1159
:param opts: Options (not used)
1160
:return: always returns the unmodified working document
1161

1162
Useful for testing.
1163

1164
**Examples**
1165

1166
.. code-block:: yaml
1167

1168
    - signcerts
1169
    """
1170
    if req.t is None:
3×
1171
        raise PipeException("Your pipeline is missing a select statement.")
3×
1172

1173
    for fp, pem in list(xmlsec.crypto.CertDict(req.t).items()):
!
1174
        log.info("found signing cert with fingerprint %s" % fp)
!
1175
    return req.t
!
1176

1177

1178
@pipe
3×
1179
def finalize(req, *opts):
1180
    """
1181
Prepares the working document for publication/rendering.
1182

1183
:param req: The request
1184
:param opts: Options (not used)
1185
:return: returns the working document with @Name, @cacheDuration and @validUntil set
1186

1187
Set Name, ID, cacheDuration and validUntil on the toplevel EntitiesDescriptor element of the working document. Unless
1188
explicit provided the @Name is set from the request URI if the pipeline is executed in the pyFF server. The @ID is set
1189
to a string representing the current date/time and will be prefixed with the string provided, which defaults to '_'. The
1190
@cacheDuration element must be a valid xsd duration (eg PT5H for 5 hrs) and @validUntil can be either an absolute
1191
ISO 8601 time string or (more comonly) a relative time on the form
1192

1193
.. code-block:: none
1194

1195
    \+?([0-9]+d)?\s*([0-9]+h)?\s*([0-9]+m)?\s*([0-9]+s)?
1196

1197

1198
For instance +45d 2m results in a time delta of 45 days and 2 minutes. The '+' sign is optional.
1199

1200
If operating on a single EntityDescriptor then @Name is ignored (cf :py:mod:`pyff.pipes.builtins.first`).
1201

1202
**Examples**
1203

1204
.. code-block:: yaml
1205

1206
    - finalize:
1207
        cacheDuration: PT8H
1208
        validUntil: +10d
1209
        ID: pyff
1210
    """
1211
    if req.t is None:
3×
1212
        raise PipeException("Your plumbing is missing a select statement.")
3×
1213

1214
    e = root(req.t)
3×
1215
    if e.tag == "{%s}EntitiesDescriptor" % NS['md']:
3×
1216
        name = req.args.get('name', None)
3×
1217
        if name is None or 0 == len(name):
3×
1218
            name = req.args.get('Name', None)
3×
1219
        if name is None or 0 == len(name):
3×
1220
            name = req.state.get('url', None)
3×
1221
            if name and 'baseURL' in req.args:
3×
1222

1223
                try:
!
1224
                    name_url = urlparse(name)
!
1225
                    base_url = urlparse(req.args.get('baseURL'))
!
1226
                    name = "{}://{}{}".format(base_url.scheme, base_url.netloc, name_url.path)
!
1227
                    log.debug("-------- using Name: %s" % name)
!
1228
                except ValueError as ex:
!
1229
                    log.debug(ex)
!
1230
                    name = None
!
1231
        if name is None or 0 == len(name):
3×
1232
            name = e.get('Name', None)
!
1233

1234
        if name:
3×
1235
            e.set('Name', name)
3×
1236

1237
    now = datetime.utcnow()
3×
1238

1239
    mdid = req.args.get('ID', 'prefix _')
3×
1240
    if re.match('(\s)*prefix(\s)*', mdid):
3×
1241
        prefix = re.sub('^(\s)*prefix(\s)*', '', mdid)
3×
1242
        _id = now.strftime(prefix + "%Y%m%dT%H%M%SZ")
3×
1243
    else:
1244
        _id = mdid
!
1245

1246
    if not e.get('ID'):
3×
1247
        e.set('ID', _id)
3×
1248

1249
    valid_until = str(req.args.get('validUntil', e.get('validUntil', None)))
3×
1250
    if valid_until is not None and len(valid_until) > 0:
3×
1251
        offset = duration2timedelta(valid_until)
3×
1252
        if offset is not None:
3×
1253
            dt = now + offset
3×
1254
            e.set('validUntil', dt.strftime("%Y-%m-%dT%H:%M:%SZ"))
3×
1255
        elif valid_until is not None:
!
1256
            try:
!
1257
                dt = iso8601.parse_date(valid_until)
!
1258
                dt = dt.replace(tzinfo=None)  # make dt "naive" (tz-unaware)
!
1259
                offset = dt - now
!
1260
                e.set('validUntil', dt.strftime("%Y-%m-%dT%H:%M:%SZ"))
!
1261
            except ValueError as ex:
!
1262
                log.error("Unable to parse validUntil: %s (%s)" % (valid_until, ex))
!
1263

1264
                # set a reasonable default: 50% of the validity
1265
        # we replace this below if we have cacheDuration set
1266
        req.state['cache'] = int(total_seconds(offset) / 50)
3×
1267

1268
    cache_duration = req.args.get('cacheDuration', e.get('cacheDuration', None))
3×
1269
    if cache_duration is not None and len(cache_duration) > 0:
3×
1270
        offset = duration2timedelta(cache_duration)
3×
1271
        if offset is None:
3×
1272
            raise PipeException("Unable to parse %s as xs:duration" % cache_duration)
!
1273

1274
        e.set('cacheDuration', cache_duration)
3×
1275
        req.state['cache'] = int(total_seconds(offset))
3×
1276

1277
    return req.t
3×
1278

1279

1280
@pipe(name='reginfo')
3×
1281
def _reginfo(req, *opts):
1282
    """
1283
Sets registration info extension on EntityDescription element
1284

1285
:param req: The request
1286
:param opts: Options (not used)
1287
:return: A modified working document
1288

1289
Transforms the working document by setting the specified attribute on all of the EntityDescriptor
1290
elements of the active document.
1291

1292
**Examples**
1293

1294
.. code-block:: yaml
1295

1296
    - reginfo:
1297
       [policy:
1298
            <lang>: <registration policy URL>]
1299
       authority: <registrationAuthority URL>
1300
    """
1301
    if req.t is None:
!
1302
        raise PipeException("Your pipeline is missing a select statement.")
!
1303

1304
    for e in iter_entities(req.t):
!
1305
        set_reginfo(e, **req.args)
!
1306

1307
    return req.t
!
1308

1309

1310
@pipe(name='pubinfo')
3×
1311
def _pubinfo(req, *opts):
1312
    """
1313
Sets publication info extension on EntityDescription element
1314

1315
:param req: The request
1316
:param opts: Options (not used)
1317
:return: A modified working document
1318

1319
Transforms the working document by setting the specified attribute on all of the EntityDescriptor
1320
elements of the active document.
1321

1322
**Examples**
1323

1324
.. code-block:: yaml
1325

1326
    - pubinfo:
1327
       publisher: <publisher URL>
1328
    """
1329
    if req.t is None:
!
1330
        raise PipeException("Your pipeline is missing a select statement.")
!
1331

1332
    set_pubinfo(root(req.t), **req.args)
!
1333

1334
    return req.t
!
1335

1336

1337
@pipe(name='setattr')
3×
1338
def _setattr(req, *opts):
1339
    """
1340
Sets entity attributes on the working document
1341

1342
:param req: The request
1343
:param opts: Options (not used)
1344
:return: A modified working document
1345

1346
Transforms the working document by setting the specified attribute on all of the EntityDescriptor
1347
elements of the active document.
1348

1349
**Examples**
1350

1351
.. code-block:: yaml
1352

1353
    - setattr:
1354
        attr1: value1
1355
        attr2: value2
1356
        ...
1357

1358
Normally this would be combined with the 'merge' feature of fork to add attributes to the working
1359
document for later processing.
1360
    """
1361
    if req.t is None:
3×
1362
        raise PipeException("Your pipeline is missing a select statement.")
3×
1363

1364
    for e in iter_entities(req.t):
3×
1365
        # log.debug("setting %s on %s" % (req.args,e.get('entityID')))
1366
        set_entity_attributes(e, req.args)
3×
1367
        req.store.update(e)
3×
1368

1369
    return req.t
3×
Troubleshooting · Open an Issue · Sales · Support · ENTERPRISE · CAREERS · STATUS
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2023 Coveralls, Inc